Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ The workspace currently contains source plugins compiled into the host, plus one
| `linapro-monitor-operlog` | `source` | `tenant_aware` | `tenant_scoped` | Operation log persistence and governance pages |
| `linapro-monitor-loginlog` | `source` | `tenant_aware` | `tenant_scoped` | Login log persistence and governance pages |
| `linapro-ops-demo-guard` | `source` | `tenant_aware` | `global` | Demo-environment read-only protection and global write-operation interception |
| `linapro-oidc-core` | `source` | `platform_only` | `global` | External identity linkage storage, resolve/provision engine behind the host external-login seam, and current-user identity bind/unbind/list |
| `linapro-oidc-google` | `source` | `platform_only` | `global` | Google OIDC third-party login reference on the host external-identity seam; depends on `linapro-oidc-core` |
| `linapro-oidc-discord` | `source` | `platform_only` | `global` | Discord OIDC third-party login reference on the host external-identity seam; depends on `linapro-oidc-core` |
| `linapro-demo-source` | `source` | `tenant_aware` | `tenant_scoped` | Source plugin example for menu pages, public routes, and protected routes |
| `linapro-demo-dynamic` | `dynamic` | `tenant_aware` | `tenant_scoped` | Dynamic `WASM` plugin example for embedded menu pages, plugin-owned `SQL` table `CRUD`, and standalone static pages |

Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ LinaPro 将`apps/lina-core`定位为稳定的全栈框架宿主。宿主保留
| `linapro-monitor-operlog` | `source` | `tenant_aware` | `tenant_scoped` | 操作日志持久化与治理页面 |
| `linapro-monitor-loginlog` | `source` | `tenant_aware` | `tenant_scoped` | 登录日志持久化与治理页面 |
| `linapro-ops-demo-guard` | `source` | `tenant_aware` | `global` | 演示环境只读保护与全局写操作拦截 |
| `linapro-oidc-core` | `source` | `platform_only` | `global` | 外部身份链接存储、宿主外部登录 seam 背后的解析/开户引擎,以及当前用户身份绑定/解绑/列举 |
| `linapro-oidc-google` | `source` | `platform_only` | `global` | 基于宿主外部身份 seam 的 Google OIDC 第三方登录参考实现;依赖 `linapro-oidc-core` |
| `linapro-oidc-discord` | `source` | `platform_only` | `global` | 基于宿主外部身份 seam 的 Discord OIDC 第三方登录参考实现;依赖 `linapro-oidc-core` |
| `linapro-demo-source` | `source` | `tenant_aware` | `tenant_scoped` | 源码插件菜单页面、公开路由和受保护路由示例 |
| `linapro-demo-dynamic` | `dynamic` | `tenant_aware` | `tenant_scoped` | 动态`WASM`插件示例,演示菜单内嵌页面、插件自有`SQL`表`CRUD`和独立静态页面 |

Expand Down
7 changes: 7 additions & 0 deletions linapro-oidc-core/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# LinaPro plugin Makefile.
# Shared code generation targets live in the repository root.

PLUGIN_ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
REPO_ROOT := $(abspath $(PLUGIN_ROOT)/../../..)

include $(REPO_ROOT)/hack/makefiles/plugin.codegen.mk
62 changes: 62 additions & 0 deletions linapro-oidc-core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# linapro-oidc-core

`linapro-oidc-core` is the official LinaPro source plugin that owns external identity storage and the provider engine behind the host external-login seam.

English | [简体中文](README.zh-CN.md)

## Scope

This plugin owns:

- the `user_external_identity` linkage table mapping a verified `(provider, subject)` pair to a local user account
- the `externalidentityspi.Provider` engine: resolution, auto-provisioning policy (same-email conflict rejection, email-less deterministic username anchors, idempotent `(provider, subject)` de-duplication), and least-privilege account creation delegated back to the host user owner through `usercap.ProvisionExternal`
- the current-user identity binding API: list, bind, and unbind under `/x/linapro-oidc-core/api/v1/plugins/linapro-oidc-core/identities`

OAuth protocol plugins such as `linapro-oidc-google` and `linapro-oidc-discord` depend on this plugin. They keep verifying identities and calling the host `externallogin` seam unchanged; this plugin never calls `LoginByVerifiedIdentity` and declares no provider-ID ownership.

## Host Boundary

The host keeps token minting, session persistence, tenant resolution, pre-login-token handoff, login-IP policy, and auth hooks. This plugin registers only an engine factory through `plugin.Providers().ProvideExternalIdentityProvider(...)`; the host manager lazily constructs the provider gated by plugin enablement and binds the manager-backed seam into the auth service.

When this plugin is not installed or is disabled, external login is fail-closed: no linkage resolves, no account is provisioned, and no session is issued. Re-enabling the plugin restores external login immediately. Disabling keeps the linkage table data; uninstalling with storage purge drops the table but never cascade-deletes provisioned user accounts, which stay governable through host user management.

## Data Permission Boundary

External identity linkages are self-isolated user resources:

- login resolution uses only the authoritative `(provider, subject)` partial unique index and reports a uniform not-provisioned outcome, never leaking whether an email exists on another account
- bind, unbind, and list act exclusively on the current session user's own linkages; cross-user targets are rejected as a whole
- a `(provider, subject)` pair already owned by another account is rejected with a conflict error; re-binding an identity the current user already owns succeeds idempotently

## Provisioning Contract

Host account provisioning and the plugin linkage write cannot share one database transaction across module boundaries. Correctness converges on the `(provider, subject)` partial unique index: the account is provisioned first, then the linkage is inserted; a unique-index conflict from a concurrent provision is absorbed by reusing the winning linkage instead of surfacing a 500. Email-less identities derive a deterministic, collision-resistant username anchor from the identity digest, so retries reuse the same host account.

## Directory Layout

```text
linapro-oidc-core/
plugin.yaml
plugin_embed.go
go.mod
Makefile
backend/
plugin.go
api/identity/v1/ list/bind/unbind DTOs
internal/
controller/identity/ current-user identity binding handlers
service/identity/ provider engine and linkage policy
dao/, model/do/, model/entity/ generated table access objects
manifest/
sql/ install SQL for user_external_identity
sql/uninstall/ uninstall SQL (drop table on purge)
i18n/en-US/, i18n/zh-CN/ error and API documentation resources
hack/config.yaml plugin-local DAO generation config
```

## Review Checklist

- linkage storage, provisioning policy, and binding stay inside this plugin
- token, session, and tenant minting stay host-owned
- the engine factory is declared through `ProvideExternalIdentityProvider`; no provider-ID ownership is declared
- disable keeps linkage data and fails external login closed; uninstall purge drops only the plugin table
62 changes: 62 additions & 0 deletions linapro-oidc-core/README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# linapro-oidc-core

`linapro-oidc-core`是 LinaPro 官方源码插件,拥有外部身份存储和宿主外部登录 seam 背后的 provider 引擎。

[English](README.md) | 简体中文

## 能力边界

本插件拥有:

- `user_external_identity`链接表,把已验证的`(provider, subject)`键映射到本地用户账号
- `externalidentityspi.Provider`引擎:身份解析、自动开户策略(同邮箱冲突拒绝、无邮箱确定性用户名 anchor、幂等`(provider, subject)`去重),以及经`usercap.ProvisionExternal`委托回宿主用户域的最小权限建号
- 当前用户身份绑定 API:`/x/linapro-oidc-core/api/v1/plugins/linapro-oidc-core/identities`下的列举、绑定与解绑

`linapro-oidc-google`和`linapro-oidc-discord`等 OAuth 协议插件依赖本插件。它们继续负责身份验签并调用宿主`externallogin` seam,路径不变;本插件从不调用`LoginByVerifiedIdentity`,也不声明任何 provider ID 归属。

## 宿主边界

宿主保留 token 铸造、会话持久化、租户解析、预登录令牌交接、登录 IP 策略与认证 hook。本插件只通过`plugin.Providers().ProvideExternalIdentityProvider(...)`注册引擎工厂;宿主 manager 按插件启用状态惰性构造 provider,并把 manager-backed seam 绑定进 auth 服务。

本插件未安装或被禁用时,外部登录 fail-closed:不解析链接、不开户、不签发会话。重新启用后外部登录立即恢复。禁用保留链接表数据;卸载并确认清除数据时删除该表,但绝不级联删除已开户的用户账号,这些账号仍可通过宿主用户管理治理。

## 数据权限边界

外部身份链接是用户自隔离资源:

- 登录解析仅使用权威的`(provider, subject)`部分唯一索引,并返回统一的未开户结果,绝不泄露某邮箱是否存在于其他账号
- 绑定、解绑与列举仅作用于当前会话用户自己的链接;越权目标整体拒绝
- `(provider, subject)`已被其他账号占用时返回冲突错误;重复绑定当前用户已拥有的身份幂等成功

## 开户契约

宿主建号与插件链接写入跨模块边界,无法共享单个数据库事务。正确性收敛在`(provider, subject)`部分唯一索引上:先建号、再写链接;并发开户触发唯一索引冲突时复用胜出链接,而不是冒泡 500。无邮箱身份从身份摘要派生确定性、碰撞抗性的用户名 anchor,重试会复用同一宿主账号。

## 目录结构

```text
linapro-oidc-core/
plugin.yaml
plugin_embed.go
go.mod
Makefile
backend/
plugin.go
api/identity/v1/ list/bind/unbind DTO
internal/
controller/identity/ current-user identity binding handlers
service/identity/ provider engine and linkage policy
dao/, model/do/, model/entity/ generated table access objects
manifest/
sql/ install SQL for user_external_identity
sql/uninstall/ uninstall SQL (drop table on purge)
i18n/en-US/, i18n/zh-CN/ error and API documentation resources
hack/config.yaml plugin-local DAO generation config
```

## 审查清单

- 链接存储、开户策略与绑定逻辑保持在本插件内
- token、会话与租户铸造保持宿主独占
- 引擎工厂通过`ProvideExternalIdentityProvider`声明;不声明 provider ID 归属
- 禁用保留链接数据并使外部登录 fail-closed;卸载清除仅删除插件自有表
17 changes: 17 additions & 0 deletions linapro-oidc-core/backend/api/identity/identity.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions linapro-oidc-core/backend/api/identity/v1/identity_bind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// identity_bind.go defines the request and response DTOs for binding one
// verified external identity to the current session user.

package v1

import "github.com/gogf/gf/v2/frame/g"

// BindIdentityReq is the request for binding one verified external identity to
// the current session user.
type BindIdentityReq struct {
g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"post" tags:"External Identity" summary:"Bind an external identity" dc:"Link one verified external identity (provider + subject) to the current session user. The caller must have already verified the identity through the owning OAuth plugin; this endpoint performs no OAuth exchange. Binding only acts on the current session user, and a (provider, subject) pair already owned by another account is rejected with a conflict error. Re-binding an identity the current user already owns succeeds idempotently."`
Provider string `json:"provider" v:"required|length:1,64" dc:"Stable external provider ID owned by the verifying OAuth plugin" eg:"google"`
Subject string `json:"subject" v:"required|length:1,191" dc:"Immutable provider-issued subject identifier obtained from the verified identity" eg:"110169484474386276334"`
Email string `json:"email" v:"length:0,191" dc:"Optional verified email captured as an audit snapshot only; never used as a resolution key" eg:"user@example.com"`
}

// BindIdentityRes is the response for binding one external identity.
type BindIdentityRes struct{}
26 changes: 26 additions & 0 deletions linapro-oidc-core/backend/api/identity/v1/identity_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// identity_list.go defines the request and response DTOs for listing the
// current session user's bound external identities.

package v1

import "github.com/gogf/gf/v2/frame/g"

// ListIdentitiesReq is the request for listing the current session user's bound
// external identities.
type ListIdentitiesReq struct {
g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"get" tags:"External Identity" summary:"List bound external identities" dc:"Return the external identities bound to the current session user. The result is strictly self-isolated: it never exposes other accounts' linkages, so no extra permission node is required beyond authentication."`
}

// ListIdentitiesRes is the response for listing the current session user's
// bound external identities.
type ListIdentitiesRes struct {
// Items contains the current user's bound external identities.
Items []BoundIdentityItem `json:"items" dc:"Bound external identities of the current session user"`
}

// BoundIdentityItem is one bound external identity projection.
type BoundIdentityItem struct {
Provider string `json:"provider" dc:"Stable external provider ID, e.g. google or discord" eg:"google"`
Subject string `json:"subject" dc:"Immutable provider-issued subject identifier" eg:"110169484474386276334"`
Email string `json:"email" dc:"Email snapshot captured at link time for display only; may be empty for email-less providers" eg:"user@example.com"`
}
17 changes: 17 additions & 0 deletions linapro-oidc-core/backend/api/identity/v1/identity_unbind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// identity_unbind.go defines the request and response DTOs for unbinding one of
// the current session user's external identities.

package v1

import "github.com/gogf/gf/v2/frame/g"

// UnbindIdentityReq is the request for unbinding one of the current session
// user's external identities.
type UnbindIdentityReq struct {
g.Meta `path:"/plugins/linapro-oidc-core/identities" method:"delete" tags:"External Identity" summary:"Unbind an external identity" dc:"Remove one external identity linkage from the current session user. Unbinding only acts on the current session user's own linkages; a linkage that does not belong to the current user is reported as not found without leaking other accounts' linkage existence. Unbinding frees the (provider, subject) key for a future relink."`
Provider string `json:"provider" v:"required|length:1,64" dc:"Stable external provider ID of the linkage to remove" eg:"google"`
Subject string `json:"subject" v:"required|length:1,191" dc:"Immutable provider-issued subject identifier of the linkage to remove" eg:"110169484474386276334"`
}

// UnbindIdentityRes is the response for unbinding one external identity.
type UnbindIdentityRes struct{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// =================================================================================
// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish.
// =================================================================================

package identity
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// =================================================================================
// This is auto-generated by GoFrame CLI tool only once. Fill this file as you wish.
// =================================================================================

package identity

import (
"lina-core/pkg/plugin/capability/bizctxcap"
"lina-plugin-linapro-oidc-core/backend/api/identity"
identitysvc "lina-plugin-linapro-oidc-core/backend/internal/service/identity"
)

// ControllerV1 handles the current-user external identity binding API.
type ControllerV1 struct {
identitySvc *identitysvc.Service // external identity linkage service
bizCtxSvc bizctxcap.Service // current request business context
}

// NewV1 creates and returns a new external identity controller instance.
func NewV1(identitySvc *identitysvc.Service, bizCtxSvc bizctxcap.Service) identity.IIdentityV1 {
return &ControllerV1{identitySvc: identitySvc, bizCtxSvc: bizCtxSvc}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package identity

import (
"context"

"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"

"lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi"
v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1"
)

// apiBindPluginID stamps API-driven binds with the plugin that owns the linkage
// storage; OAuth-flow binds are stamped with the verifying plugin by the host.
const apiBindPluginID = "linapro-oidc-core"

// BindIdentity links one verified external identity to the current session user.
func (c *ControllerV1) BindIdentity(ctx context.Context, req *v1.BindIdentityReq) (res *v1.BindIdentityRes, err error) {
current := c.bizCtxSvc.Current(ctx)
if current.UserID <= 0 {
return nil, gerror.NewCode(gcode.CodeNotAuthorized)
}
if err = c.identitySvc.Bind(ctx, externalidentityspi.BindInput{
UserID: current.UserID,
Provider: req.Provider,
Subject: req.Subject,
Email: req.Email,
PluginID: apiBindPluginID,
}); err != nil {
return nil, err
}
return &v1.BindIdentityRes{}, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package identity

import (
"context"

"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"

v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1"
)

// ListIdentities returns the current session user's bound external identities.
func (c *ControllerV1) ListIdentities(ctx context.Context, _ *v1.ListIdentitiesReq) (res *v1.ListIdentitiesRes, err error) {
current := c.bizCtxSvc.Current(ctx)
if current.UserID <= 0 {
return nil, gerror.NewCode(gcode.CodeNotAuthorized)
}
identities, err := c.identitySvc.List(ctx, current.UserID)
if err != nil {
return nil, err
}
items := make([]v1.BoundIdentityItem, 0, len(identities))
for _, identity := range identities {
items = append(items, v1.BoundIdentityItem{
Provider: identity.Provider,
Subject: identity.Subject,
Email: identity.Email,
})
}
return &v1.ListIdentitiesRes{Items: items}, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package identity

import (
"context"

"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"

"lina-core/pkg/plugin/capability/authcap/externallogin/externalidentityspi"
v1 "lina-plugin-linapro-oidc-core/backend/api/identity/v1"
)

// UnbindIdentity removes one of the current session user's external identities.
func (c *ControllerV1) UnbindIdentity(ctx context.Context, req *v1.UnbindIdentityReq) (res *v1.UnbindIdentityRes, err error) {
current := c.bizCtxSvc.Current(ctx)
if current.UserID <= 0 {
return nil, gerror.NewCode(gcode.CodeNotAuthorized)
}
if err = c.identitySvc.Unbind(ctx, externalidentityspi.UnbindInput{
UserID: current.UserID,
Provider: req.Provider,
Subject: req.Subject,
}); err != nil {
return nil, err
}
return &v1.UnbindIdentityRes{}, nil
}
Loading