mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
750 lines
37 KiB
Markdown
750 lines
37 KiB
Markdown
# skillhub 认证与授权设计
|
||
|
||
> 外部身份架构说明:LDAP、DingTalk、CAS、SAML、可信代理及其他新外部身份接入,
|
||
> 以 [统一身份联邦设计](./21-unified-identity-federation-design.md) 为准。GitHub、
|
||
> GitLab 和标准 OIDC 已迁入统一身份核心;Credential、Passive 和 Browser Adapter
|
||
> 已由 Provider Registry 统一发现和路由。旧名称 `DirectAuthProvider` 和
|
||
> `PassiveSessionAuthenticator` 仅是待删除的源码迁移别名,已经不能返回
|
||
> `PlatformPrincipal`。新 Provider 只能返回协议验证结果,由统一核心归一化为内部
|
||
> `IdentityAssertion`。
|
||
|
||
## 0. 身份标识约束
|
||
|
||
- `PlatformPrincipal.userId` 必须是稳定的字符串标识,而不是 `Long`。
|
||
- 用户身份在系统内的主契约是字符串 `userId`;认证、授权、审计、资源 owner 判定都基于该字符串进行。
|
||
- 外部身份源的 `subject`、企业 SSO UID、工号型字符串等都必须可以原样或经确定性映射后进入系统,禁止先压缩成自增整数再作为正式用户主键在全链路传播。
|
||
- 历史草案里的整型用户主键描述全部废弃,当前认证与授权设计只承认字符串身份主键。
|
||
|
||
## 1. 认证架构
|
||
|
||
```
|
||
请求进入
|
||
│
|
||
▼
|
||
┌─────────────────────────────┐
|
||
│ Layer 1: OAuth2/OIDC Login │ Spring Security OAuth2 Client
|
||
│ (GitHub/GitLab/OIDC 可扩展) │ 授权码模式 (Authorization Code)
|
||
│ Layer 1b: Session Bootstrap│ 显式被动会话引导(默认关闭)
|
||
└─────────────┬───────────────┘
|
||
│ OAuth2User
|
||
▼
|
||
┌─────────────────────────────┐
|
||
│ Layer 2: Identity Core │ 受信 descriptor + Authority Lock
|
||
│ │ Assertion Factory + 账号状态守卫
|
||
└─────────────┬───────────────┘
|
||
│ IdentityAssertion
|
||
▼
|
||
┌─────────────────────────────┐
|
||
│ Layer 3: Policy + Mapping │ Login / Provisioning Policy
|
||
│ │ Binding V2 + 字段来源与资料同步
|
||
└─────────────┬───────────────┘
|
||
│ PlatformPrincipal
|
||
▼
|
||
┌─────────────────────────────┐
|
||
│ Layer 4: Session / Token │ Web: Spring Session (Redis)
|
||
│ │ CLI: Device Flow + Bearer Token
|
||
└─────────────┬───────────────┘
|
||
│ SecurityContext
|
||
▼
|
||
┌─────────────────────────────┐
|
||
│ Layer 5: Authorization │ RBAC + 资源级判定
|
||
└─────────────────────────────┘
|
||
```
|
||
|
||
## 2. 准入策略(Access Policy)
|
||
|
||
OAuth 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。
|
||
|
||
```java
|
||
// 基于统一身份上下文的准入策略,与底层协议无关
|
||
public interface AccessPolicy {
|
||
AccessDecision evaluate(IdentityAccessContext context);
|
||
}
|
||
|
||
public record IdentityAccessContext(
|
||
String providerCode,
|
||
String subjectType,
|
||
String subject,
|
||
Optional<String> email,
|
||
EmailAssurance emailAssurance,
|
||
IdentityLoginContext requestContext,
|
||
IdentityAccessKind accessKind,
|
||
Optional<UserStatus> existingAccountStatus
|
||
) {}
|
||
|
||
public enum AccessDecision {
|
||
ALLOW, // 本次登录准入
|
||
DENY // 拒绝,不建立 Session,重定向到拒绝页
|
||
}
|
||
```
|
||
|
||
### 2.1 一期支持的策略(通过配置切换)
|
||
|
||
```yaml
|
||
skillhub:
|
||
access-policy:
|
||
mode: EMAIL_DOMAIN # OPEN / PROVIDER_ALLOWLIST / EMAIL_DOMAIN / SUBJECT_WHITELIST
|
||
allowed-providers:
|
||
- github
|
||
allowed-email-domains:
|
||
- company.com
|
||
- subsidiary.com
|
||
```
|
||
|
||
| 策略 | 判定依据 | 说明 |
|
||
|------|---------|------|
|
||
| `OPEN` | 无限制 | 所有 OAuth 登录用户自动准入 |
|
||
| `PROVIDER_ALLOWLIST` | `context.providerCode` | 仅允许指定 Provider 登录 |
|
||
| `EMAIL_DOMAIN` | `context.email` + `context.emailAssurance` | 仅允许 `VERIFIED` / `AUTHORITATIVE` 邮箱且域名匹配 |
|
||
| `SUBJECT_WHITELIST` | `context.providerCode` + `context.subject` | 按 `provider:subject` 白名单,管理员预添加 |
|
||
|
||
### 2.2 准入失败处理
|
||
|
||
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
|
||
|
||
安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得
|
||
业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。
|
||
|
||
### 2.3 首次建号策略(Provisioning Policy)
|
||
|
||
Login Policy 每次登录执行;Provisioning Policy 只在外部身份尚未绑定时执行。二者不能
|
||
再通过 `AccessDecision` 混合表达。
|
||
|
||
| 模式 | 未绑定身份的行为 |
|
||
|------|------------------|
|
||
| `AUTO` | 创建 `ACTIVE` Account、Binding、typed Subjects 和 `@global MEMBER` |
|
||
| `APPROVAL` | 创建 `PENDING` Account、Binding 和 typed Subjects,不建立 Session |
|
||
| `EXISTING_BINDING_ONLY` | 不创建任何记录,返回 `ACCESS_DENIED` |
|
||
|
||
`APPROVAL` 下,相同身份重复登录继续命中原 Binding 并返回 `ACCOUNT_PENDING`,不会重复
|
||
建号。管理员批准时在同一事务把账号改为 `ACTIVE` 并补齐 `@global MEMBER`;拒绝时改为
|
||
`DISABLED` 并保留 Binding,防止反复创建 PENDING 账号。
|
||
|
||
配置是受信 descriptor 的一部分,按 Provider Instance 生效:
|
||
|
||
```yaml
|
||
skillhub:
|
||
auth:
|
||
identity:
|
||
providers:
|
||
corp-oidc:
|
||
provisioning-mode: APPROVAL
|
||
profile-sync:
|
||
display-name: PRESERVE_LOCAL
|
||
email: FILL_IF_EMPTY
|
||
avatar-url: INITIAL_ONLY
|
||
```
|
||
|
||
### 2.4 资料同步策略
|
||
|
||
`user_profile_field_source` 记录 `displayName`、`email`、`avatarUrl` 当前值来自 Provider、
|
||
用户、管理员还是历史本地数据。升级迁移把已有非空值标记为 `LEGACY_LOCAL`,避免升级后
|
||
第一次外部登录覆盖历史资料。
|
||
|
||
每个字段支持 `NEVER`、`INITIAL_ONLY`、`FILL_IF_EMPTY`、`PRESERVE_LOCAL` 和
|
||
`PROVIDER_AUTHORITATIVE`。默认 displayName/avatarUrl 使用 `PRESERVE_LOCAL`,email 使用
|
||
`FILL_IF_EMPTY`,且 email 只有 `VERIFIED` / `AUTHORITATIVE` 才能写入。显式设置
|
||
`PROVIDER_AUTHORITATIVE` 后,登录 Provider 可以覆盖本地值;这项例外必须配置在具体
|
||
Provider 和具体字段上。
|
||
|
||
### 2.5 Email 碰撞
|
||
|
||
未绑定身份携带可信 email,而平台已有相同 email 时,核心只返回
|
||
`LinkRequired("EMAIL_COLLISION")`:
|
||
|
||
- 不按 email 自动绑定账号;
|
||
- 不返回目标 userId、账号资料或可直接完成绑定的 token;
|
||
- 不创建 Account、Binding 或 Subject;
|
||
- PR 5 的显式 Identity Link 完成前只展示安全提示和已有账号登录入口。
|
||
|
||
### 2.6 扩展性
|
||
|
||
后续新增 OAuth Provider(Google、GitLab、微信)时,准入策略与 Provider 无关,统一在 AccessPolicy 层判定,不需要重做入驻逻辑。
|
||
|
||
## 3. Web 认证流程(OAuth2 / OIDC Authorization Code)
|
||
|
||
```
|
||
浏览器点击"登录"
|
||
│
|
||
▼
|
||
前端跳转: /oauth2/authorization/github
|
||
│
|
||
▼
|
||
Spring Security 重定向到 GitHub 授权页
|
||
│
|
||
▼
|
||
用户在 GitHub 授权
|
||
│
|
||
▼
|
||
GitHub 回调: /login/oauth2/code/github?code=xxx&state=xxx
|
||
│
|
||
▼
|
||
Spring Security 自动完成:
|
||
① 用 code 换取 access_token
|
||
② 调用 GitHub API 获取用户信息
|
||
③ 触发自定义 OAuth2UserService
|
||
│
|
||
▼
|
||
CustomOAuth2UserService / CustomOidcUserService:
|
||
① Adapter 从已验证响应提取 ProviderAuthenticationResult
|
||
② 服务端路由解析 ResolvedProviderHandle
|
||
③ 统一身份核心读取受信 descriptor,执行 Authority pin/复核
|
||
④ Assertion Factory 固定 provider/authority/subject/属性映射
|
||
⑤ 解析全部 Subject,锁定已有 Binding / Account
|
||
⑥ Account Guard + AccessPolicy.evaluate(IdentityAccessContext)
|
||
│
|
||
├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied(不建立 Session)
|
||
└── ALLOW ↓
|
||
│
|
||
⑦ 已绑定 → 按字段来源和 Profile Sync Policy 同步允许字段
|
||
└── 未绑定 → Provisioning Policy + email collision 检查
|
||
├── AUTO → 创建 ACTIVE Account + Binding + Subjects + membership
|
||
├── APPROVAL → 创建 PENDING Account + Binding + Subjects
|
||
└── EXISTING_BINDING_ONLY → 拒绝且不写入
|
||
│
|
||
▼
|
||
AuthenticationSuccessHandler:
|
||
① 创建 Spring Session (Redis)
|
||
② 重定向到前端页面 (可配置的 redirect_uri)
|
||
```
|
||
|
||
OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserService`
|
||
分支处理。`CustomOidcUserService` 会把标准 OIDC claims 映射为
|
||
`ProviderAuthenticationResult`:
|
||
|
||
- Subject candidate:类型固定为 `oidc_sub`,值为大小写敏感的 OIDC `sub`
|
||
- 属性事实:`email`、`email_verified`、`preferred_username`、`name`、`picture`
|
||
- 协议证据:只包含 `oidc`、认证时间和认证方法,不包含 token 或原始响应
|
||
- Provider code、issuer Authority 和最终属性映射由服务端受信 descriptor 固定
|
||
|
||
`identity_binding(provider_code, subject)` 保留兼容 primary 值,
|
||
`identity_binding_subject` 保存 typed primary/alias,并通过数据库约束保证一个 ACTIVE
|
||
Binding 恰有一个 ACTIVE primary。`identity_provider_state` 只保存 Provider code、
|
||
protocol、canonical Authority、SHA-256 fingerprint 和状态,不保存 client secret 或 token。
|
||
同一 registration id 切换 issuer 时进入粘性的 `AUTHORITY_MISMATCH`,不展示登录方式,
|
||
也不接受回调;恢复旧 Authority 后仍需显式恢复操作。
|
||
|
||
### 3.1 统一 Session 建立约束
|
||
|
||
所有 Web 登录入口都必须通过统一的 `PlatformSessionService` 建立登录态,包括:
|
||
|
||
- 本地用户名密码登录
|
||
- OAuth 登录成功回调
|
||
- `POST /api/v1/auth/direct/login`
|
||
- `POST /api/v1/auth/session/bootstrap`
|
||
- 本地开发态 `MockAuthFilter`
|
||
|
||
统一约束如下:
|
||
|
||
- 统一写入 `platformPrincipal`
|
||
- 统一写入 `SPRING_SECURITY_CONTEXT`
|
||
- 统一通过 `HttpSession` 持久化,确保 Spring Session Redis 能无差别接管
|
||
- 交互式登录默认调用 `changeSessionId()`,降低 session fixation 风险
|
||
- 已由 Spring Security 完成认证的入口可以复用现有 `Authentication`,避免重复构造认证结果
|
||
|
||
这意味着未来私有版新增企业 SSO provider 时,只能扩展认证来源本身,不能绕开统一的 session 建立服务直接操作 Session。
|
||
|
||
## 3.3 Session Bootstrap 扩展点
|
||
|
||
为了兼容未来私有部署中的企业 SSO 被动登录,开源版预留显式会话引导协议:
|
||
|
||
- 接口:`POST /api/v1/auth/session/bootstrap`
|
||
- 用途:前端在同域场景下显式触发一次“读取外部会话并尝试换取 skillhub Session”的流程
|
||
- 默认状态:关闭,开源版不提供任何 `PassiveAuthenticationAdapter` 实现
|
||
- 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度
|
||
|
||
扩展接口如下:
|
||
|
||
```java
|
||
public interface PassiveAuthenticationAdapter {
|
||
ProviderInstanceDefinition provider();
|
||
Optional<ProviderAuthenticationResult> authenticate(
|
||
PassiveAuthenticationRequest request
|
||
);
|
||
}
|
||
```
|
||
|
||
约束如下:
|
||
|
||
- Registry 先确认 Provider `READY`,再调用 `authenticate()`
|
||
- App 层把 Servlet 请求转换成不可变 `PassiveAuthenticationRequest`;Adapter 不能访问
|
||
`HttpSession`、Servlet API 或 `SecurityContext`
|
||
- `authenticate()` 只负责验证外部被动会话并返回非敏感协议事实
|
||
- 统一身份核心负责账号、Binding、审批和 `PlatformPrincipal`
|
||
- 断言存在但无效、重放或上游不可用时,Adapter 抛出只含稳定失败码的
|
||
`ProviderAuthenticationException`
|
||
- 是否允许启用该入口由 `skillhub.auth.session-bootstrap.enabled` 控制,默认 `false`
|
||
- 未启用时接口返回 `403`
|
||
- 启用但 provider 不受支持时返回 `400`
|
||
- 启用但请求中不存在有效外部会话时返回 `401`
|
||
- 成功时建立标准 Spring Security Session,并返回与 `/api/v1/auth/me` 一致的用户结构
|
||
|
||
## 3.4 Direct Authentication 扩展点
|
||
|
||
为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象:
|
||
|
||
```java
|
||
public interface CredentialAuthenticationAdapter {
|
||
ProviderInstanceDefinition provider();
|
||
ProviderAuthenticationResult authenticate(
|
||
CredentialAuthenticationRequest request
|
||
);
|
||
}
|
||
```
|
||
|
||
对应公共协议:
|
||
|
||
- `POST /api/v1/auth/direct/login`
|
||
|
||
约束如下:
|
||
|
||
- 开源版默认关闭,由 `skillhub.auth.direct.enabled` 控制
|
||
- 关闭时返回 `403`
|
||
- provider 不受支持时返回 `400`
|
||
- provider 认证失败时使用 `ProviderAuthenticationFailureCode` 的稳定分类
|
||
- Provider 非 `READY` 时不会把凭证发送给 Adapter
|
||
- Adapter 不创建账号、Binding、角色或 Session
|
||
- 凭证无效、TLS、超时、配置或响应错误不会把上游详情写入用户响应
|
||
- 成功时建立标准 Session,并返回与 `/api/v1/auth/me` 一致的用户结构
|
||
- 现有 `/api/v1/auth/local/login` 保持不变,兼容层只是新增可选入口
|
||
|
||
### 3.5 Spring Security 配置要点
|
||
|
||
```java
|
||
@Configuration
|
||
@EnableWebSecurity
|
||
public class SecurityConfig {
|
||
|
||
@Bean
|
||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||
http
|
||
.oauth2Login(oauth2 -> oauth2
|
||
.userInfoEndpoint(info -> info
|
||
.userService(customOAuth2UserService))
|
||
.successHandler(oAuth2SuccessHandler)
|
||
.failureHandler(oAuth2FailureHandler)
|
||
)
|
||
.sessionManagement(session -> session
|
||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
|
||
.csrf(csrf -> csrf
|
||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||
.ignoringRequestMatchers("/api/v1/**"))
|
||
// ...
|
||
;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 Provider 配置、启动协调和扩展边界
|
||
|
||
当前静态 descriptor source 只接受已配置且可唯一解析的 GitHub、GitLab 和标准 OIDC:
|
||
|
||
```yaml
|
||
# application.yml
|
||
spring:
|
||
security:
|
||
oauth2:
|
||
client:
|
||
registration:
|
||
github:
|
||
client-id: ${OAUTH2_GITHUB_CLIENT_ID}
|
||
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET}
|
||
scope: read:user,user:email
|
||
# 二期扩展示例:
|
||
# gitlab:
|
||
# client-id: ...
|
||
# authorization-grant-type: authorization_code
|
||
# google:
|
||
# client-id: ...
|
||
```
|
||
|
||
应用启动时固定执行以下顺序:
|
||
|
||
1. 从唯一的受信 descriptor source 读取已启用配置。
|
||
2. 对每个 Provider 在 PostgreSQL 中执行 Authority compare-and-set pin。
|
||
3. 再次读取持久化状态。
|
||
4. 登录目录每次读取时再次以持久化状态过滤;只有状态为 `READY` 且 fingerprint 与
|
||
当前 descriptor 一致的 Provider 才进入 `/api/v1/auth/providers` 和
|
||
`/api/v1/auth/methods`。
|
||
|
||
配置缺失、placeholder、未知/歧义协议、Authority 无法唯一确定、未 pin 或 mismatch
|
||
都 fail closed。授权入口和 callback 在 Spring Security 发起上游重定向、Token 交换或
|
||
userinfo 请求前执行相同的持久化 readiness 检查;登录目录不会直接读取
|
||
`OAuth2ClientProperties`,其他 Pod 写入的 mismatch 也不会被旧的内存投影继续展示。
|
||
|
||
运维把全部 Pod 恢复为已 pin 的相同 Authority 后,由 `SUPER_ADMIN` 调用:
|
||
|
||
```text
|
||
POST /api/v1/admin/identity-providers/{providerCode}/authority/recover
|
||
```
|
||
|
||
该操作不接受新 Authority 或 fingerprint,只能在数据库仍为 `AUTHORITY_MISMATCH` 且当前
|
||
受信 descriptor 的 fingerprint 等于已 pin 值时 compare-and-set 回 `READY`。成功变更
|
||
写入同一事务的 `PROVIDER_AUTHORITY_RECOVERED` 审计;重复调用返回
|
||
`recovered=false, state=READY`,不会伪造第二条恢复审计。配置仍指向新 Authority 时返回
|
||
冲突,必须等待后续独立的 Authority 迁移设计,不能用此接口改写 pin。
|
||
|
||
新增协议不能只添加 Spring registration 或在 OAuth user service 中加分支。必须按照
|
||
[统一身份联邦设计](./21-unified-identity-federation-design.md) 实现受信 descriptor、
|
||
协议 Adapter 和 conformance 测试;LDAP、DingTalk、CAS、SAML、SCIM 及动态 Provider
|
||
Registry 不属于当前阶段。
|
||
|
||
## 4. 核心接口设计
|
||
|
||
```java
|
||
public interface ExternalIdentityLoginService {
|
||
IdentityLoginOutcome authenticate(
|
||
ResolvedProviderHandle provider,
|
||
ProviderAuthenticationResult result,
|
||
IdentityLoginContext context
|
||
);
|
||
}
|
||
```
|
||
|
||
`ResolvedProviderHandle` 只能由服务端 `ClientRegistration` 路由解析产生。
|
||
`ProviderAuthenticationResult` 不含 Provider code、Authority、平台 userId、角色、
|
||
Principal、Session、token、ticket、Cookie 或原始响应。核心内部按固定顺序执行:
|
||
|
||
```text
|
||
Trusted descriptor
|
||
→ Authority Lock
|
||
→ IdentityAssertionFactory
|
||
→ Binding / Subject resolution
|
||
→ AccountLoginGuard
|
||
→ AccessPolicy
|
||
→ ProvisioningPolicy / email collision
|
||
→ ProfileSyncPolicy
|
||
→ PlatformPrincipalFactory
|
||
→ IdentityLoginOutcome
|
||
```
|
||
|
||
只有 `IdentityLoginOutcome.Authenticated` 可以到达既有 `PlatformSessionService`。当前
|
||
`PlatformPrincipal` 结构保持不变;Binding V2 和 profile source 使用 additive migration
|
||
及兼容列支持升级和回滚。
|
||
|
||
### 4.1 多 Provider 账号合并策略
|
||
|
||
同一个员工通过不同 OAuth Provider 登录时,可能产生多个 `user_account`。
|
||
|
||
当前策略:不自动合并,旧的手动合并流程也已临时隔离。旧流程把次账号 verification
|
||
token 直接返回给主账号会话,不能分别证明两个账号的控制权,因此不能继续作为管理员或
|
||
用户合并入口。
|
||
|
||
- 当前阶段:不自动合并,每个 Provider 登录独立创建用户
|
||
- 多 Provider 上线时,再引入显式 Identity Link 和安全 Account Merge
|
||
- email、username、display name 或主账号会话拿到的 token 均不能证明次账号所有权
|
||
- 安全 Account Merge 必须要求主、次账号分别完成 fresh reauthentication
|
||
- 在安全流程上线前,`/api/v1/account/merge/initiate`、`verify`、`confirm` 对已认证请求
|
||
统一返回 `503 Service Unavailable`
|
||
|
||
合并操作规则:
|
||
- 合并操作写入审计日志
|
||
- 合并后原 user_account 标记为 `MERGED`,保留记录不物理删除
|
||
- 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权
|
||
的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
|
||
- 旧 `account_merge_request` 记录保留用于审计和未来迁移,但不得通过 SQL 手工改为
|
||
`VERIFIED`/`COMPLETED`,也不得手工迁移身份、角色、membership、凭据或 Token
|
||
- 回滚到仍包含旧合并实现的镜像会重新暴露该安全问题;如必须回滚,应先在网关阻断
|
||
`/api/v1/account/merge/*`
|
||
|
||
未来安全流程的完整验收条件见
|
||
[`22-secure-account-merge-acceptance-design.md`](./22-secure-account-merge-acceptance-design.md)。
|
||
|
||
## 5. CLI 认证(OAuth Device Flow + 平台凭证)
|
||
|
||
CLI 主认证基线调整为 OAuth Device Flow。用户在 CLI 中发起授权,浏览器侧完成登录与确认,CLI 轮询后获取平台签发的凭证并访问 CLI API。
|
||
|
||
- 发起:CLI 请求 device code,展示 `user_code` 与验证地址
|
||
- 授权:用户在浏览器完成 GitHub OAuth 登录并确认绑定
|
||
- 轮询:CLI 使用 `device_code` 轮询授权结果
|
||
- 完成:服务端签发 CLI 可用凭证,CLI 持 `Authorization: Bearer <token>` 调用后续接口
|
||
|
||
API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台通用凭证能力”:
|
||
|
||
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
|
||
- 存储:只存 SHA-256 哈希,明文只展示一次
|
||
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
|
||
- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份;Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401,即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session;若无 Session,公共读接口按匿名访问,`whoami` 返回 401
|
||
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
|
||
- 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
|
||
|
||
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
|
||
|
||
## 6. RBAC 授权判定
|
||
|
||
```
|
||
权限判定 = 平台角色权限(role → permission 查询) ∪ 命名空间角色(namespace_member.role)
|
||
```
|
||
|
||
一期即上线完整 RBAC,平台角色按最小权限拆分:
|
||
|
||
| 平台角色 | 职责 |
|
||
|---------|------|
|
||
| `SUPER_ADMIN` | 全部权限,硬判定短路 |
|
||
| `SKILL_ADMIN` | 全局空间审核、提升审核、隐藏/恢复技能、撤回已发布版本 |
|
||
| `USER_ADMIN` | 准入审批、封禁/解封、角色分配(不可分配 SUPER_ADMIN) |
|
||
| `AUDITOR` | 审计日志只读 |
|
||
|
||
- 命名空间权限仍由 `namespace_member.role`(OWNER / ADMIN / MEMBER)决定
|
||
- 一个用户可持有多个平台角色
|
||
- 普通用户无平台角色,仅通过 namespace 成员关系获得操作权限
|
||
|
||
判定逻辑:
|
||
1. 从 SecurityContext 获取当前用户
|
||
2. 检查用户状态(`DISABLED` → 拒绝所有操作)
|
||
3. 查询用户的平台角色(`user_role_binding` → `role` → `role_permission`)
|
||
4. `SUPER_ADMIN` 短路:直接通过所有权限检查
|
||
5. 如果涉及命名空间资源,查询用户在该命名空间的角色(`namespace_member.role`)
|
||
6. 检查命名空间状态(`FROZEN` → 拒绝写操作)
|
||
7. 合并平台权限 + 命名空间角色,判定是否满足
|
||
|
||
| 操作 | 所需权限 | 判定逻辑 |
|
||
|------|---------|---------|
|
||
| 发布技能包 | `skill:publish` | 普通用户要求是目标 namespace 成员;`SUPER_ADMIN` 可绕过成员校验并直发 |
|
||
| 提交已有版本进入审核 | `review:submit` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` |
|
||
| 管理技能(归档/版本管理) | `skill:manage` | namespace ADMIN 以上,或 owner 本人 |
|
||
| 提升到全局 | `skill:promote` | namespace ADMIN 以上,或 owner 本人 |
|
||
| 审核技能发布 | `review:approve` | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN`;提交人本人仅 `SUPER_ADMIN` 可审核自己的 review task |
|
||
| 审核提升申请 | `promotion:approve` | 持有 SKILL_ADMIN / SUPER_ADMIN |
|
||
| 隐藏/恢复技能 | `skill:manage` | 仅 `SUPER_ADMIN` |
|
||
| 撤回已发布版本(YANK) | `skill:manage` | `SKILL_ADMIN` / `SUPER_ADMIN` |
|
||
| 管理用户角色 | `user:manage` | 持有 USER_ADMIN / SUPER_ADMIN |
|
||
| 审批用户准入 | `user:approve` | 持有 USER_ADMIN / SUPER_ADMIN |
|
||
| 查看审计日志 | `audit:read` | 持有 AUDITOR / SUPER_ADMIN |
|
||
|
||
权限主轴说明:
|
||
- namespace role 是权限主轴,namespace ADMIN 对空间内所有 skill 有完整管理权,不受 owner 限制
|
||
- `owner_id` 语义为"主要维护人",owner 作为 MEMBER 时仅可管理自己创建的 skill
|
||
- 企业场景人员流动频繁,owner 离职后 namespace ADMIN 仍能完整管理所有技能
|
||
|
||
### 6.1 审核与提升 API 路径适用范围
|
||
|
||
| API 路径 | 适用范围 | 权限要求 |
|
||
|----------|---------|---------|
|
||
| `POST /api/v1/reviews/{id}/approve` | 技能发布审核 | namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` |
|
||
| `POST /api/v1/promotions/{id}/approve` | 提升到全局审核 | `SKILL_ADMIN` / `SUPER_ADMIN` |
|
||
| `GET /api/v1/admin/audit-logs` | 审计日志查询 | AUDITOR / SUPER_ADMIN |
|
||
| `PUT /api/v1/admin/users/{id}/roles` | 用户角色管理 | USER_ADMIN / SUPER_ADMIN |
|
||
| `POST /api/v1/admin/users/{id}/approve` | 用户准入审批 | USER_ADMIN / SUPER_ADMIN |
|
||
|
||
当前实现中,审核与提升都走统一 portal API;是否允许操作由服务层根据 namespace role 与 platform role 联合判定,而不是靠分叉路由表达。
|
||
|
||
## 7. Session 设计
|
||
|
||
- 存储:Spring Session + Redis(必须,多 Pod 环境刚需)
|
||
- 序列化:JSON
|
||
- 过期:默认 8 小时,Redis TTL 自动清理
|
||
|
||
### 7.1 Session 内容
|
||
|
||
Session 中存储以下字段:
|
||
- `userId`:平台用户 ID
|
||
- `displayName`:展示名
|
||
- `oauthProvider`:登录使用的 OAuth Provider
|
||
- `currentNamespaceId`:当前选中的命名空间(可选)
|
||
- `platformRoles`:平台角色列表(如 `["SKILL_ADMIN", "AUDITOR"]`),登录时从 `user_role_binding` → `role` 查询写入
|
||
- `roleVersion`:角色版本号,用于缓存一致性
|
||
|
||
### 7.2 角色缓存一致性机制
|
||
|
||
平台角色变更需要即时生效(如撤销审核权限),不能等 Session 过期:
|
||
|
||
1. 每次请求时从 Session 读取 `roleVersion`
|
||
2. 与 Redis 中的 `user:{userId}:roleVersion` 比对
|
||
3. 版本一致 → 直接使用 Session 中的 `platformRoles`
|
||
4. 版本不一致 → 从数据库重新加载角色,更新 Session
|
||
|
||
管理员修改用户角色时,递增 Redis 中该用户的 `roleVersion`。
|
||
|
||
## 8. CSRF 防护
|
||
|
||
采用 Cookie-to-Header 模式:
|
||
- 后端设置 `XSRF-TOKEN` Cookie(`HttpOnly=false`)
|
||
- 前端从 Cookie 读取 Token,放入请求 Header `X-XSRF-TOKEN`
|
||
- 后端校验 Header 与 Cookie 是否一致
|
||
- CLI API(`/api/v1/**`)与兼容层(`/api/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie)
|
||
|
||
## 9. 前端权限控制
|
||
|
||
### 9.1 `/api/v1/auth/me` 响应结构
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"msg": "获取成功",
|
||
"data": {
|
||
"userId": "usr_42",
|
||
"displayName": "zhangsan",
|
||
"email": "zhangsan@company.com",
|
||
"avatarUrl": "https://...",
|
||
"oauthProvider": "local",
|
||
"canChangePassword": true,
|
||
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
|
||
},
|
||
"timestamp": "2026-03-12T06:00:00Z",
|
||
"requestId": "req-123"
|
||
}
|
||
```
|
||
|
||
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`。后端通过 `role_permission` 表查询权限码。
|
||
|
||
统一约束:
|
||
- `/api/v1/auth/me`、`/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
|
||
- `/api/v1/auth/session/bootstrap` 也必须遵守同一统一响应结构。
|
||
- `msg` 必须走 Spring Boot 标准 `MessageSource` i18n 机制。
|
||
- locale 必须通过请求上下文自动获取,不在 controller 中显式传递。
|
||
- 认证失败返回 `401`,但 JSON 外层结构仍保持一致,例如 `{"code":401,"msg":"需要先登录","data":null,...}`。
|
||
|
||
### 9.2 usePermission() Hook
|
||
|
||
```typescript
|
||
function usePermission() {
|
||
const { data: me } = useQuery({ queryKey: ['auth', 'me'], queryFn: fetchMe })
|
||
|
||
const hasRole = (role: string) => me?.platformRoles.includes(role) ?? false
|
||
const isSuperAdmin = () => hasRole('SUPER_ADMIN')
|
||
const isSkillAdmin = () => hasRole('SKILL_ADMIN') || isSuperAdmin()
|
||
const isUserAdmin = () => hasRole('USER_ADMIN') || isSuperAdmin()
|
||
const isAuditor = () => hasRole('AUDITOR') || isSuperAdmin()
|
||
|
||
return {
|
||
isLoggedIn: !!me,
|
||
isSuperAdmin,
|
||
isSkillAdmin,
|
||
isUserAdmin,
|
||
isAuditor,
|
||
|
||
// 命名空间角色判定
|
||
getNamespaceRole: (slug: string) =>
|
||
me?.namespaces.find(n => n.slug === slug)?.role,
|
||
isNamespaceAdmin: (slug: string) =>
|
||
['OWNER', 'ADMIN'].includes(me?.namespaces.find(n => n.slug === slug)?.role ?? ''),
|
||
isNamespaceMember: (slug: string) =>
|
||
['OWNER', 'ADMIN', 'MEMBER'].includes(me?.namespaces.find(n => n.slug === slug)?.role ?? ''),
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.3 路由级守卫
|
||
|
||
在 TanStack Router `beforeLoad` 中判定:
|
||
|
||
| 路由 | 条件 |
|
||
|------|------|
|
||
| `/dashboard/*` | 已登录 |
|
||
| `/dashboard/namespaces/{slug}/reviews` | 已登录 + 该 namespace 的 ADMIN 以上 |
|
||
| `/admin/*` | 已登录 + 持有任一平台角色(SUPER_ADMIN / SKILL_ADMIN / USER_ADMIN / AUDITOR) |
|
||
|
||
不满足条件时:未登录 → 重定向登录;已登录但无权限 → 显示 403 页面。
|
||
|
||
### 9.4 操作级控制
|
||
|
||
| 场景 | 判定逻辑 | UI 行为 |
|
||
|------|---------|---------|
|
||
| 技能详情页"提交发布"按钮 | `isNamespaceMember(namespace)` | 非成员不显示 |
|
||
| 审核列表"通过/拒绝"按钮 | 团队空间:`isNamespaceAdmin(namespace)`;全局空间:`isSkillAdmin()` | 无权限不显示 |
|
||
| 用户管理页 | `isUserAdmin()` | 无权限不显示 |
|
||
| 用户管理页"设为 SUPER_ADMIN" | `isSuperAdmin()` | 仅超管可见 |
|
||
| 审计日志页 | `isAuditor()` | 无权限不显示 |
|
||
| 技能详情页"归档"按钮 | `isNamespaceAdmin(namespace)` 或当前用户是 owner | 否则不显示 |
|
||
| 命名空间"添加成员"按钮 | `isNamespaceAdmin(namespace)` | 非管理员不显示 |
|
||
| 收藏/评分按钮 | `isLoggedIn` | 未登录时点击提示登录 |
|
||
|
||
### 9.5 登录交互
|
||
|
||
```
|
||
前端登录按钮
|
||
│
|
||
▼
|
||
window.location.href = '/oauth2/authorization/github'
|
||
│
|
||
▼
|
||
(后端 OAuth2 流程,用户无感)
|
||
│
|
||
▼
|
||
回调后重定向到前端 (如 /?login=success)
|
||
│
|
||
▼
|
||
前端检测 URL 参数 → 调用 /api/v1/auth/me → 更新登录态
|
||
```
|
||
|
||
前端无需引入额外 OAuth 库,登录流程完全由后端 Spring Security 处理。前端只需:
|
||
- 调用 `/api/v1/auth/providers` 获取可用 Provider 列表,动态渲染登录按钮
|
||
- 处理登录后的重定向
|
||
- 通过 `/api/v1/auth/me` 检测登录状态
|
||
|
||
### 9.6 安全边界原则
|
||
|
||
- 前端权限控制是 UX 优化,不是安全边界
|
||
- 后端每个写操作接口独立校验权限,不信任前端判定
|
||
- 前端隐藏按钮 ≠ 安全,用户可以直接调 API,后端必须拦截
|
||
|
||
## 10. 权限矩阵(完整)
|
||
|
||
以下矩阵列出每个 API 接口的权限判定来源,作为后端实现的唯一参考。
|
||
|
||
### 10.1 Public API(匿名可访问)
|
||
|
||
| 接口 | 匿名 | 已登录 | 判定逻辑 |
|
||
|------|------|--------|---------|
|
||
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin)` | `SearchVisibilityScope` + 搜索索引状态 |
|
||
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
|
||
| `GET /api/v1/skills/{ns}/{slug}/versions` | 仅 `PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
|
||
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
|
||
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` |
|
||
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
|
||
|
||
### 10.2 Authenticated API
|
||
|
||
| 接口 | 所需权限 | 判定来源 |
|
||
|------|---------|---------|
|
||
| `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token |
|
||
| `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token |
|
||
| `POST /api/v1/reviews` | owner 本人,或 namespace `ADMIN` / `OWNER`,或 `SKILL_ADMIN` / `SUPER_ADMIN` | `skill.owner_id` / `namespace_member.role` / platform roles |
|
||
| `POST .../versions/{ver}/withdraw-review` | 提交人本人 | `review_task.submitted_by` |
|
||
| `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
|
||
| `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
|
||
| `POST .../versions/{ver}/rerelease` | namespace ADMIN 以上 或 owner;源版本必须 `PUBLISHED` | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` |
|
||
| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 `DRAFT` / `REJECTED`) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` |
|
||
|
||
### 10.3 CLI API
|
||
|
||
| 接口 | 凭证规则 | 授权与错误语义 |
|
||
|------|---------|---------------|
|
||
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401;坏 Bearer 即使存在 Session 也返回 401 |
|
||
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill;有效 Bearer 覆盖 Session;坏 Bearer 返回 401,不得降级 |
|
||
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session;坏 Bearer 返回 401;有效身份无资源权限返回 403 |
|
||
|
||
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session,确保请求使用 token 的用户、角色与 scope;Bearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401,不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session;如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403;服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`。
|
||
|
||
### 10.4 Admin API
|
||
|
||
| 接口 | 所需平台角色 | 判定来源 |
|
||
|------|------------|---------|
|
||
| `POST /api/v1/admin/skills/{id}/hide` | SUPER_ADMIN | `user_role_binding` → `role_permission` |
|
||
| `POST /api/v1/admin/skills/{id}/unhide` | SUPER_ADMIN | 同上 |
|
||
| `POST /api/v1/admin/skills/versions/{versionId}/yank` | SKILL_ADMIN / SUPER_ADMIN | 同上 |
|
||
| `PUT /api/v1/admin/users/{id}/roles` | USER_ADMIN / SUPER_ADMIN | 同上,且 USER_ADMIN 不可分配 SUPER_ADMIN |
|
||
| `POST /api/v1/admin/users/{id}/approve` | USER_ADMIN / SUPER_ADMIN | 同上 |
|
||
| `POST /api/v1/admin/users/{id}/ban` | USER_ADMIN / SUPER_ADMIN | 同上 |
|
||
| `GET /api/v1/admin/audit-logs` | AUDITOR / SUPER_ADMIN | 同上 |
|
||
|
||
### 10.5 Namespace API
|
||
|
||
| 接口 | 所需 namespace 角色 | 判定来源 |
|
||
|------|-------------------|---------|
|
||
| `POST /api/v1/namespaces/{slug}/members` | 该空间 ADMIN 以上 | `namespace_member.role` |
|
||
| `DELETE /api/v1/namespaces/{slug}/members/{userId}` | 该空间 ADMIN 以上 | `namespace_member.role` |
|
||
| `POST /api/v1/promotions` | 该空间 ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
|
||
|
||
### 10.6 Compatibility API(Bearer Token 认证)
|
||
|
||
| 接口 | 所需凭证 | 额外判定 |
|
||
|------|---------|---------|
|
||
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
|
||
| `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` |
|
||
| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` |
|
||
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC`、`ACTIVE`、非 hidden、命名空间未归档且目标版本可安装) | visibility + namespace status + `SkillInstallability` |
|
||
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) |
|