AGENTS.md and CONTRIBUTING.md both require docs updates when auth flows or deployment config change; this batch changed both and touched no docs. 03-authentication-design.md described adding a provider as "branch on registrationId inside CustomOAuth2UserService", which the ProviderOAuth2UserService strategy supersedes. Rewrites that recipe: register an OAuthClaimsExtractor bean per provider, add a ProviderOAuth2UserService only when the userinfo response is non-standard, and note that the login page needs no code change. Also records the provider-side obligations that are easy to get wrong -- stable subject with no fallback, emailVerified only on proven ownership, bounded remote calls, no subject in logs -- and un-comments the config example, which still listed GitLab as a future possibility. faq.md told operators to delete "the github and gitlab blocks" to hide SSO buttons. That advice was already incomplete and gets worse per provider, so it now explains the config-driven mechanism: an empty client id keeps the entry off the login page, no file edit needed. 09-deployment.md listed only the GitHub credentials. Adds GitLab and Feishu, and flags a deployment trap: Feishu emails are admin-imported so emailVerified is always false, and skillhub.access-policy.mode=EMAIL_DOMAIN denies every unverified email, which would reject all Feishu logins. Squares the Feishu logo viewBox. It was 407.87x324.19 while login-button renders it in a square w-5 h-5 box, so the mark was distorted. Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
34 KiB
skillhub 认证与授权设计
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: Access Policy │ 准入策略判定
│ (认证成功 ≠ 有权使用平台) │ 白名单/邮箱域名/开放注册
└─────────────┬───────────────┘
│ 准入通过
▼
┌─────────────────────────────┐
│ Layer 3: Identity Mapping │ OAuth2 用户 → 平台用户
│ (查询/创建 identity_binding) │ 自动注册 + 信息同步
└─────────────┬───────────────┘
│ PlatformPrincipal
▼
┌─────────────────────────────┐
│ Layer 4: Session / Token │ Web: Spring Session (Redis)
│ │ CLI: Device Flow + Bearer Token
└─────────────┬───────────────┘
│ SecurityContext
▼
┌─────────────────────────────┐
│ Layer 5: Authorization │ RBAC + 资源级判定
└─────────────────────────────┘
2. 准入策略(Access Policy)
OAuth 认证成功仅代表身份可信,不代表有权使用平台。准入层在认证成功后、创建平台用户前执行。
// 基于 claims 的准入策略,与 Provider 无关
public interface AccessPolicy {
AccessDecision evaluate(OAuthClaims claims);
}
public record OAuthClaims(
String provider, // github, google, wechat
String subject, // provider 唯一 ID
String email, // nullable(微信等可能无邮箱)
boolean emailVerified, // 是否已验证
String providerLogin, // 如 GitHub login
Map<String, Object> extra
) {}
public enum AccessDecision {
ALLOW, // 准入,继续创建/绑定平台用户
DENY, // 拒绝,不建立 Session,重定向到拒绝页
PENDING_APPROVAL // 等待管理员审批,不建立业务 Session
}
2.1 一期支持的策略(通过配置切换)
astron:
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 |
claims.provider |
仅允许指定 Provider 登录 |
EMAIL_DOMAIN |
claims.email + claims.emailVerified |
仅允许已验证邮箱且域名匹配(email 为空或未验证则 DENY) |
SUBJECT_WHITELIST |
claims.provider + claims.subject |
按 provider:subject 白名单,管理员预添加 |
2.2 准入失败处理
DENY:抛出OAuth2AccessDeniedException,由failureHandler重定向到/access-denied页面。不创建用户,不建立 Session。PENDING_APPROVAL:创建user_account(status=PENDING),但不建立业务 Session。抛出AccountPendingException,由failureHandler重定向到/pending-approval页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为ACTIVE,用户下次 OAuth 登录才会正常建立 Session。
安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得 业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。
2.3 扩展性
后续新增 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:
① 从 OAuth2User 提取 provider + externalId → 构建 OAuthClaims
② AccessPolicy.evaluate(claims) → 准入判定
│
├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied(不建立 Session)
├── PENDING_APPROVAL → 创建 PENDING 用户 → 抛出 AccountPendingException → failureHandler 重定向 /pending-approval(不建立 Session)
└── ALLOW ↓
│
③ 查询 identity_binding 是否已绑定
├── 已绑定 → 加载平台用户,检查用户状态(DISABLED → 抛异常),同步最新头像/昵称
└── 未绑定 → 创建 user_account(ACTIVE) + identity_binding
│
▼
AuthenticationSuccessHandler:
① 创建 Spring Session (Redis)
② 重定向到前端页面 (可配置的 redirect_uri)
OIDC 登录沿用同一条业务链路,但由 Spring Security 的 oidcUserService
分支处理。CustomOidcUserService 会把标准 OIDC claims 映射为
OAuthClaims:
provider:Spring OAuth2 client registration id,例如okta、keycloak或oidcsubject:OIDCsubemail/emailVerified:email与email_verifiedproviderLogin:优先preferred_username,其次name、email、subpicture会同步为avatar_url,供现有头像同步逻辑复用
因此 OIDC 不需要新增数据库表;现有 identity_binding(provider_code, subject) 可以保存任意 OIDC issuer 下的稳定用户标识。不同 IdP 应使用不同
registration id,避免多个 issuer 的 sub 值空间混用。
3.1 统一 Session 建立约束
所有 Web 登录入口都必须通过统一的 PlatformSessionService 建立登录态,包括:
- 本地用户名密码登录
- OAuth 登录成功回调
POST /api/v1/auth/direct/loginPOST /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”的流程
- 默认状态:关闭,开源版不提供任何
PassiveSessionAuthenticator实现 - 安全边界:默认不做全局自动登录 filter,避免匿名访问时隐式建会话、放大 CSRF 和审计复杂度
扩展接口如下:
public interface PassiveSessionAuthenticator {
String providerCode();
Optional<PlatformPrincipal> authenticate(HttpServletRequest request);
}
约束如下:
authenticate()只负责验证外部被动会话并返回平台登录所需主体- 是否允许启用该入口由
skillhub.auth.session-bootstrap.enabled控制,默认false - 未启用时接口返回
403 - 启用但 provider 不受支持时返回
400 - 启用但请求中不存在有效外部会话时返回
401 - 成功时建立标准 Spring Security Session,并返回与
/api/v1/auth/me一致的用户结构
3.4 Direct Authentication 扩展点
为兼容未来“前端收集用户名密码,后端调用企业 SSO / RPC 校验”的私有部署模式,开源版增加默认关闭的直连认证抽象:
public interface DirectAuthProvider {
String providerCode();
PlatformPrincipal authenticate(DirectAuthRequest request);
}
对应公共协议:
POST /api/v1/auth/direct/login
约束如下:
- 开源版默认关闭,由
skillhub.auth.direct.enabled控制 - 关闭时返回
403 - provider 不受支持时返回
400 - provider 认证失败时沿用 provider 自身的认证异常语义
- 成功时建立标准 Session,并返回与
/api/v1/auth/me一致的用户结构 - 现有
/api/v1/auth/local/login保持不变,兼容层只是新增可选入口
3.5 Spring Security 配置要点
@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 OAuth2 Provider 扩展设计
一期只实现 GitHub,但架构支持后续扩展:
# 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: ${OAUTH2_GITLAB_CLIENT_ID}
client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET}
authorization-grant-type: authorization_code
feishu:
client-id: ${OAUTH2_FEISHU_CLIENT_ID}
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET}
# 飞书的 scope 配在开放平台应用上,不在这里传
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
Spring Security OAuth2 Client 原生支持多 Provider 并存,新增 Provider 只需:
application.yml添加 registration 与 provider 配置- 实现一个
OAuthClaimsExtractor,把该 Provider 的属性映射成统一的OAuthClaims - 登录页无需改代码:
/api/v1/auth/methods只返回配置了真实 client id 的注册, 图标按 provider 名解析为/{provider}-logo.svg
第 2 步是按 Provider 注册一个 Bean,而不是在某个类里按 registrationId 分支。
账号匹配、建号、资料权威和账号守卫都在 OAuthClaims 之后共享,Provider 自己不做这些决策。
如果该 Provider 的 userinfo 响应不是标准的扁平结构(例如飞书用
{code, msg, data} 信封,且以 HTTP 200 返回错误),再额外实现一个
ProviderOAuth2UserService:它声明自己负责哪个 registrationId,
接管 userinfo 的加载步骤,其余流程不变。该覆盖运行在
RemoteIdentityIoExecutor 边界内,因此 Provider 的 HTTP 调用不会持有数据库事务。
Provider 侧还需遵守:subject 必须稳定(不要用可能在两次登录间变化的字段做
fallback,否则同一个人会被拆成两个平台账号)、只有在 Provider 真正证明了邮箱
所有权时才置 emailVerified=true、远程调用要有超时与响应大小上限、
claims 提取过程不记录 subject/email/token。
4. 核心接口设计
// 自定义 OAuth2 用户服务,处理准入 + 用户映射
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
OAuth2User oAuth2User = super.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId();
// 提取标准化 claims(传入 accessToken 用于调用 Provider API,如 GitHub /user/emails)
OAuthClaims claims = OAuthClaimsExtractor.extract(registrationId, oAuth2User, request.getAccessToken());
// 准入策略判定(基于 claims,与 Provider 无关)
AccessDecision decision = accessPolicy.evaluate(claims);
if (decision == AccessDecision.DENY) {
throw new OAuth2AccessDeniedException("Access denied by policy");
}
if (decision == AccessDecision.PENDING_APPROVAL) {
// 创建 PENDING 用户但不返回有效 principal,不建立业务 Session
identityBindingService.createPendingUser(registrationId, claims);
throw new AccountPendingException("Account pending approval");
}
// 绑定或创建平台用户(仅 ALLOW 才走到这里)
UserAccount account = identityBindingService.bindOrCreate(registrationId, claims);
if (account.getStatus() == UserStatus.DISABLED) {
throw new AccountDisabledException("Account is disabled");
}
return new PlatformOAuth2User(account, oAuth2User.getAuthorities());
}
}
// 按 Provider 提取标准化 claims(每个 Provider 有自己的可信字段契约)
public class OAuthClaimsExtractor {
public static OAuthClaims extract(String registrationId, OAuth2User user,
OAuth2AccessToken accessToken) {
return switch (registrationId) {
case "github" -> extractGitHub(user, accessToken);
// 后续扩展其他 Provider
default -> throw new OAuth2AuthenticationException("Unsupported provider: " + registrationId);
};
}
// GitHub: 公开 email 可能为空,需调用 /user/emails API 获取已验证邮箱
private static OAuthClaims extractGitHub(OAuth2User user, OAuth2AccessToken accessToken) {
String verifiedEmail = GitHubEmailFetcher.fetchVerifiedEmail(accessToken);
return new OAuthClaims(
"github",
String.valueOf(user.getAttribute("id")),
verifiedEmail, // 从 /user/emails 获取的已验证邮箱,可能为 null
verifiedEmail != null, // 只有确认 verified 才为 true
user.getAttribute("login"),
Map.of("avatar_url", user.getAttribute("avatar_url"))
);
}
// GitHubEmailFetcher: 调用 GitHub /user/emails API,
// 返回 primary + verified 的邮箱,无则返回 null
}
4.1 多 Provider 账号合并策略
同一个员工通过不同 OAuth Provider 登录时,可能产生多个 user_account。
一期策略:默认关闭自动合并,仅支持管理员手动合并。
- 一期 GitHub-only:不需要自动合并,每个 Provider 登录独立创建用户
- 多 Provider 上线时,再引入显式绑定/合并流程(用户主动发起 + 邮箱验证确认)
- 管理员可在后台手动合并两个 user_account(合并 identity_binding、迁移 skill ownership、合并角色取并集)
合并操作规则:
- 合并操作写入审计日志
- 合并后原 user_account 标记为
MERGED,保留记录不物理删除 - 不提供按 email 自动合并;即使 Provider 声明 email 已验证,也不能替代对两个账号控制权 的分别证明。未来绑定/合并必须使用显式、可审计的重新认证流程。
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 - 默认权限:Web 自助创建和 CLI Device Flow 均签发
skill:read、skill:publish、skill:delete;token:manage仅在调用方显式请求时授予 - 拒绝原因:API Token 缺少作用域或不能访问某个接口时,403 响应返回本地化的安全原因和
requestId;其他授权失败仍返回通用信息,避免暴露内部异常
一期作用域说明(非最小权限):一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含
skill:publishscope)都可以向该 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 成员关系获得操作权限
判定逻辑:
- 从 SecurityContext 获取当前用户
- 检查用户状态(
DISABLED→ 拒绝所有操作) - 查询用户的平台角色(
user_role_binding→role→role_permission) SUPER_ADMIN短路:直接通过所有权限检查- 如果涉及命名空间资源,查询用户在该命名空间的角色(
namespace_member.role) - 检查命名空间状态(
FROZEN→ 拒绝写操作) - 合并平台权限 + 命名空间角色,判定是否满足
| 操作 | 所需权限 | 判定逻辑 |
|---|---|---|
| 发布技能包 | 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:平台用户 IDdisplayName:展示名oauthProvider:登录使用的 OAuth ProvidercurrentNamespaceId:当前选中的命名空间(可选)platformRoles:平台角色列表(如["SKILL_ADMIN", "AUDITOR"]),登录时从user_role_binding→role查询写入roleVersion:角色版本号,用于缓存一致性
7.2 角色缓存一致性机制
平台角色变更需要即时生效(如撤销审核权限),不能等 Session 过期:
- 每次请求时从 Session 读取
roleVersion - 与 Redis 中的
user:{userId}:roleVersion比对 - 版本一致 → 直接使用 Session 中的
platformRoles - 版本不一致 → 从数据库重新加载角色,更新 Session
管理员修改用户角色时,递增 Redis 中该用户的 roleVersion。
8. CSRF 防护
采用 Cookie-to-Header 模式:
- 后端设置
XSRF-TOKENCookie(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 响应结构
{
"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 标准MessageSourcei18n 机制。- locale 必须通过请求上下文自动获取,不在 controller 中显式传递。
- 认证失败返回
401,但 JSON 外层结构仍保持一致,例如{"code":401,"msg":"需要先登录","data":null,...}。
9.2 usePermission() Hook
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 |
POST .../versions/{ver}/yank |
namespace ADMIN 以上 或 owner;版本必须 PUBLISHED;API Token 需 skill:publish |
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 解析) |