# 鲲 Galgame 开发文档 — full content --- ## OAuth 2.0 端点 · /contracts/oauth/01-oauth-endpoints # OAuth 2.0 端点 返回 [README](./README.md) 本节是 OAuth 2.0 Authorization Code(含 PKCE)+ Refresh Token 协议的端点契约。完整的对接走查(环境变量、回调处理、并发刷新、安全注意事项)请看 [oauth-integration-guide.md](./oauth-integration-guide.md),Nuxt 快速上手见 [nuxt-integration-prompt.md](./nuxt-integration-prompt.md)。 | 端点 | 方法 | 鉴权 | 用途 | |------|------|------|------| | `/oauth/authorize` | GET | 终端用户 Bearer Token | 用户同意授权后拿授权码 | | `/oauth/token` | POST | client_id+secret(或 PKCE) | 授权码 / refresh_token 换 access_token | | `/oauth/userinfo` | GET | Bearer Token | 读用户公开信息(受 scope 控制) | | `/oauth/revoke` | POST | 不需要 | 主动吊销 token(登出) | | `/oauth/logout` | GET | 不需要 | RP 登出入口:顶层导航,302 跳 OP 前端登出页(见 [07](./07-logout.md))| | `/oauth/post-logout-redirect` | GET | 不需要 | 登出回跳白名单校验(见 [07](./07-logout.md))| --- ## POST /oauth/token 用授权码或刷新令牌换取 access token。 **请求体(授权码模式)**: ```json { "grant_type": "authorization_code", "code": "64位hex授权码", "redirect_uri": "https://www.kungal.com/auth/callback", "client_id": "your-client-id", "client_secret": "your-client-secret", "code_verifier": "PKCE验证器(如果authorize时使用了code_challenge)" } ``` **请求体(刷新令牌模式)**: ```json { "grant_type": "refresh_token", "refresh_token": "eyJhbGc...", "client_id": "your-client-id", "client_secret": "your-client-secret" } ``` **成功响应**: ```json { "code": 0, "message": "成功", "data": { "access_token": "eyJhbGc...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "eyJhbGc...", "scope": "openid profile" } } ``` | 字段 | 说明 | |------|------| | access_token | JWT,有效期 15 分钟 | | token_type | 固定 "Bearer" | | expires_in | 900 秒(15 分钟) | | refresh_token | JWT,有效期 7 天。每次刷新会轮换 | | scope | 可选,回显授权时的 scope | > **限流**:此端点用 client_id 维度限流(不是 IP),所以 kungal/moyu 这种走 SSR 后端代理整个用户群的 confidential client 不会被踢到匿名 IP 桶里。一般不会撞到上限。 --- ## GET /oauth/authorize 获取授权码。用户必须已登录(带 Bearer Token)。 **查询参数**: | 参数 | 必填 | 说明 | |------|------|------| | client_id | 是 | OAuth 客户端 ID | | redirect_uri | 是 | 回调地址,必须与注册时一致 | | response_type | 是 | 固定 `code` | | state | 是 | 随机字符串,防 CSRF | | scope | 否 | 权限范围,空格分隔 | | code_challenge | 否 | PKCE code challenge | | code_challenge_method | 否 | `S256`(默认)或 `plain` | | prompt | 否 | `login` = 强制重新登录(即使 OP 仍有会话也不静默放行);见 [07-logout.md](./07-logout.md) | **成功响应**:HTTP 302 重定向到 `redirect_uri?code=xxx&state=xxx` **授权码有效期**:10 分钟,一次性使用。 --- ## GET /oauth/userinfo 获取当前登录用户信息。OIDC 标准端点;scope 控制下哪些字段会被返回。 **请求头**:`Authorization: Bearer ` **成功响应**: ```json { "code": 0, "message": "成功", "data": { "id": 12345, "sub": "550e8400-e29b-41d4-a716-446655440000", "name": "KUN", "email": "kun@kungal.com", "picture": "https://...", "roles": ["user", "admin"], "updated_at": 1234567890 } } ``` | 字段 | 说明 | |------|------| | id | 用户整数 ID(= OAuth `users.id`,与 kungal/moyu 业务表的 `user_id` 外键对齐) | | sub | 用户 UUID(OIDC 标准的 subject),与 `id` 标识同一用户,调用方任选其一 | | name | 用户名(仅 `profile` scope 或空 scope 时返回) | | email | 邮箱(仅 `email` scope 或空 scope 时返回) | | picture | 头像 URL(仅 `profile` scope 或空 scope 时返回,可能为空) | | roles | 角色名称数组,与 JWT `roles` claim 一致 | | updated_at | 最后更新时间(Unix 时间戳) | **关于 scope 与字段过滤**: `id`、`sub`、`roles` 始终返回(不被 scope 过滤)—— 因为这三项已经在 JWT 里,调用方既然能用这个 JWT 调 /userinfo,就已经拿到了这些信息,再隐藏没有意义。`name`、`email`、`picture` 按 OIDC 标准受 `profile` / `email` scope 控制。 > **跨服务接入提示**:kungal/moyu/galgame_wiki 后端处理 OAuth callback 时,应该在登录环节就拿 `id` 入库(作为本地 user 表的主键 / 外键),不要只存 `sub` —— 后续业务表关联、`/users/batch` 批量回拉、SDK 缓存键,全部基于 `id` 整数键。 > > 想拿更全字段(如 `moemoepoint`、`bio`、`avatar_image_hash`)可以走 [GET /auth/me](./02-user-profile.md#get-authme);想批量回拉多个用户走 [GET /users/batch](./03-cross-service.md#get-usersbatch)。 --- ## POST /oauth/revoke 吊销令牌。遵循 RFC 7009,无论成功失败都返回 200。 **请求体**: ```json { "token": "要吊销的 refresh_token" } ``` --- --- ## 登出(RP-Initiated Logout) 登出 / 单点登出是单独的跨服务契约,完整说明见 [07-logout.md](./07-logout.md)。要点: - RP 登出须**顶层跳转**到 OP 登出入口 `GET {OAUTH_API_BASE}/oauth/logout?client_id=&redirect=`(复用访问 `/oauth/authorize` 的同一 base;后端 302 跳到 OP 前端登出页清会话再回跳)。 - `GET /oauth/post-logout-redirect?client_id=&redirect=` 做回跳白名单校验(origin 匹配注册的 `redirect_uri`)。 - `GET /oauth/authorize` 的 `prompt=login` 可强制重新登录(仅登出本站语义的替代方案)。 --- 错误码(15001 - 15009)的详细含义见 [04-tokens-and-errors.md §OAuth 错误](./04-tokens-and-errors.md#oauth-错误-15xxx)。 --- ## 用户自助资料管理 · /contracts/oauth/02-user-profile # 用户自助资料管理 返回 [README](./README.md) > **重要 — 身份层操作必须在 OAuth profile 完成,下游禁止代理** > > **改邮箱、改密码、注销账号、管理登录设备**这类操作**只能**走 OAuth 自己的前端(https://oauth.kungal.com/profile)。**kungal / moyu / wiki 都不要在自己前端实现这些 UI**,即使技术上可以代理 JWT。详细分类见下表。 ## 身份操作 vs 展示操作 OAuth 的用户自助 API 在设计上分两层。下游接入时**不要**把所有写操作都拉到自己前端做——身份层修改强制走 OAuth profile,展示层可以站内提供 UI。 ### 身份层(OAuth profile 专用,下游用跳转模式) 凡是涉及"账号所有权"的操作,必须由 OAuth 自己的前端承担。原因:流程敏感、需要集中审计、未来要加 2FA / 异地通知 / 安全日志时只改一处。 | 操作 | 对应端点 | 为什么必须 OAuth | |------|------|------| | **新用户注册** | [POST /auth/register](./05-registration.md#post-authregister) | 单点身份写入;防 N 套限流 / 邮箱去重逻辑;未来加 passkey / 第三方登录时零下游成本 — 详见 [05-registration.md](./05-registration.md) | | **改邮箱** | [POST /auth/email/send-code](#post-authemailsend-code) + [PUT /auth/email](#put-authemail) | 验证码寄到**旧邮箱**,防 JWT 被窃后被攻击者改邮箱锁出。流程敏感,必须集中审计 | | **改密码** | [PUT /auth/password](#put-authpassword) | 需老密码或重置 token,账号所有权操作 | | 重设密码(忘记密码) | `POST /auth/password/forgot` + `POST /auth/password/reset` | 匿名流程,发邮件 + 一次性 token | | (未来)启用 / 关闭 2FA | 待定 | 强身份验证步骤 | | (未来)查看登录历史 / 主动下线设备 | 待定 | 跨站点 session 管理 | | (未来)绑定 / 解绑第三方登录 | 待定 | 身份联邦 | | (未来)注销账号 | 待定 | 不可逆,需冷静期 + 二次验证 | | (未来)管理已授权 OAuth Client("撤销 kungal 访问权限") | 待定 | OAuth 元操作;下游无权也无法管理别人的授权 | **下游正确做法**:在账号设置页放一个"跳转到 OAuth 账号中心"按钮,附带 `return` 参数让用户改完跳回: ```vue 修改邮箱 / 密码 ``` 下面那些端点的文档保留为**完整性**目的——OAuth 自己的前端 (apps/web) 是唯一应该调用它们的客户端。**kungal / moyu / wiki 前端不要直接 fetch 这些路径**。 ### 展示层(任何接入站都可以代理 / 自己实现 UI) | 操作 | 端点 | 说明 | |------|------|------| | 改显示名 | `PATCH /auth/me { name }` | 全局唯一,OAuth 后端拒重 | | 改头像(URL 或 hash) | `PATCH /auth/me` 或 `POST /auth/me/avatar` | 后者一步走完上传 + 写库 | | 改简介 | `PATCH /auth/me { bio }` | 纯展示,无安全性 | 这些可以站内提供 UI("代理模式"),也可以跳转("跳转模式",更一致),任选。`PATCH /auth/me` 和 `POST /auth/me/avatar` 要求带终端用户 JWT,**不是** OAuth Client Basic Auth。 --- ## 端点速览 | 端点 | 方法 | 层级 | 鉴权 | 用途 | |------|------|------|------|------| | `/auth/me` | GET | — | Bearer | 读自己完整资料 | | `/auth/me` | PATCH | 展示 | Bearer | 改 name / avatar / bio | | `/auth/me/avatar` | POST | 展示 | Bearer | 上传头像 multipart | | `/auth/email/send-code` | POST | 身份 | Bearer(仅 OAuth 前端) | 发送邮箱变更验证码到**旧**邮箱 | | `/auth/email` | PUT | 身份 | Bearer(仅 OAuth 前端) | 用验证码确认改邮箱 | | `/auth/password` | PUT | 身份 | Bearer(仅 OAuth 前端) | 改密码(需旧密码) | --- ## GET /auth/me 获取当前登录用户的完整资料。与 `/oauth/userinfo` 的区别:`/auth/me` 是面向 OAuth 自己前端的内部端点,无 scope 过滤、字段更全(含 moemoepoint)。下游服务若用得着也可以调。 **请求头**:`Authorization: Bearer ` **成功响应**: ```json { "code": 0, "data": { "uuid": "550e8400-e29b-...", "name": "kun", "email": "kun@kungal.com", "avatar": "https://...", "bio": "...", "moemoepoint": 1234, "status": 0, "roles": ["user", "admin"], "created_at": "2024-01-01T00:00:00Z" } } ``` --- ## PATCH /auth/me 修改当前登录用户的展示字段。所有字段都可选,不传的字段保持不变。 **请求头**:`Authorization: Bearer ` **请求体**: ```json { "name": "newname", "avatar": "https://...", "avatar_image_hash": "abc123...", "bio": "新简介" } ``` | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | name | string? | 1..17 字符;全局唯一;允许 Unicode 字母/数字 + `!~_@#$%^&*()+=-`,禁止零宽 / 不可见空白等 50+ 种字符(同注册规则,详见 [05-registration.md](./05-registration.md#post-authregistersend-code)) | 用户名 | | avatar | string? | ≤255 字符 | 头像 URL(legacy;image_service 普及前继续用) | | avatar_image_hash | string? | ≤64 字符 | 头像的 image_service 哈希;前端 resolveAvatarUrl 优先用此字段 | | bio | string? | ≤107 字符 | 个人简介 | 字段都用指针类型语义:**没传 = 不动;传了 = 设为该值**(包括传空字符串 = 清空)。 **成功响应**:返回更新后的完整 `UserResponse`(同 GET /auth/me 的 `data` shape)。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | 字段约束未通过(name 长度、bio 长度等) | | 400 | 10007 | name 与其他用户重复 | | 401 | 10001/10002/10003 | 未提供 / 无效 / 过期 token | **修改 email 不在这里** —— email 必须走 `/auth/email/send-code` + `/auth/email`(带验证码的两步流程,防止账号被劫持)。 **修改 password 也不在这里** —— password 必须走 `/auth/password`(需要旧密码或重置 token)。 **举例**:仅改头像 hash(image_service 上传完毕之后): ```bash curl -X PATCH https://oauth.kungal.com/api/v1/auth/me \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"avatar_image_hash":"abc123def456..."}' ``` --- ## POST /auth/me/avatar > **2026-05-23 新增**:一次性"上传头像图片 → 写入用户记录"端点,**避免下游 kungal / moyu 自己维护 image_service client**。 直接接收图片二进制(multipart),OAuth 内部转发到 image_service,并在响应返回前把拿到的 hash 写入当前用户的 `avatar_image_hash`。**调用方拿到响应时数据库已经更新**,无需再调 `PATCH /auth/me`。 **请求头**:`Authorization: Bearer ` **请求 body**:`multipart/form-data` | 字段 | 必填 | 说明 | |------|------|------| | file | 是 | 图片文件,MIME 必须 `image/*`;建议 ≤ 4 MiB(fiber 默认 body 上限) | **成功响应**:直接透传 image_service 的上传结果。 ```json { "code": 0, "data": { "hash": "abc123def456...", "url": "https://image.kungal.iloveren.link/ab/c1/abc123def456....webp", "variant_urls": { "256": "https://image.kungal.iloveren.link/ab/c1/abc123def456..._256.webp", "100": "https://image.kungal.iloveren.link/ab/c1/abc123def456..._100.webp" }, "width": 512, "height": 512, "size_bytes": 38241, "deduplicated": false } } ``` `variant_urls` 提供 256 / 100 像素的预生成缩略图,前端列表 / 评论场景直接用 100,主页 / 个人页用 256,原图(`url`)一般不需要展示。`deduplicated=true` 表示同 hash 文件以前传过,image_service 复用了已有对象,没有额外存储成本。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 8 | `file` 字段缺失或不是合法 multipart | | 401 | 10001/10002/10003 | 未提供 / 无效 / 过期 token | | 404 | 10005 | 用户记录不存在(一般 token 还有效就不会触发) | | 500 | 1 | image_service 不可达 / 配额耗尽 / 审核拒绝;详见 OAuth 服务端日志 | **和现有方式的关系**: | 方式 | 谁调 image_service | 几次请求 | 适合 | |------|------|------|------| | `POST /auth/me/avatar`(**推荐**) | OAuth 内部 | 1 次 | 标准 web / 移动端"用户改头像" | | `PATCH /auth/me { avatar_image_hash }` | **下游自己** | 2 次(先上传到 image_service 拿 hash,再 PATCH) | 下游已有 image_service client(投稿 / 截图等场景),头像和别的图片走同一上传管线 | 两种方式可以并存,下游想用哪种都行。`avatar` 和 `avatar_image_hash` 仍是独立字段(参见 PATCH /auth/me 节)。 **配额归属**:图片走的是 OAuth 自己的 image_service client,配额从 OAuth 这一侧扣,**下游 kungal / moyu 不需要为头像单独申请 image_service client**。 **CORS**:浏览器直传需要 OAuth 在 CORS 配置里允许下游 origin 上 `POST` + `Authorization` header,目前 `*.kungal.com` 已包含;新增子域接入前请确认。后端代理模式(kungal/moyu 后端接 multipart 再转发)天然不受 CORS 影响。 **举例**: ```bash curl -X POST https://oauth.kungal.com/api/v1/auth/me/avatar \ -H "Authorization: Bearer " \ -F "file=@avatar.png" ``` 浏览器版本: ```ts const fd = new FormData() fd.append('file', file) // 的 File 对象 const r = await fetch('https://oauth.kungal.com/api/v1/auth/me/avatar', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body: fd // 注意:不要手动设 Content-Type,让浏览器自动带 boundary }) const { data } = await r.json() // data.hash 已经被 OAuth 写入了用户记录,下一次 GET /auth/me 就能看到新头像 ``` --- # 身份层端点 > 下面三个端点**仅供 OAuth 自己的前端(apps/web)使用**。kungal / moyu / wiki 等下游接入站**不应直接调用**,应该跳转到 OAuth profile 让用户在那里完成。原因和跳转示例见本文档开头的"身份操作 vs 展示操作"小节。 ## POST /auth/email/send-code 发送邮箱变更验证码。**验证码寄到用户当前的旧邮箱**(不是新邮箱)—— 这是关键的安全设计:JWT 被窃后,攻击者也无法收到验证码完成劫持。 **请求头**:`Authorization: Bearer ` **请求体**: ```json { "new_email": "newaddress@example.com" } ``` | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | new_email | string | 合法邮箱格式 | 想换成的新邮箱 | **成功响应**: ```json { "code": 0, "message": "验证码已发送到当前邮箱", "data": null } ``` 验证码 6 位数字,**默认有效期 15 分钟**(由 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 配置;改邮箱 / 注册 / 任何走 Redis 6 位码的流程共用此值)。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | new_email 不是合法邮箱格式 | | 400 | 10006 | 新邮箱已被其他账号使用 | | 400 | 10012 | 上一次发送距今不足限流间隔,请稍后重试 | | 400 | 10013 | 新邮箱与当前邮箱相同 | | 401 | 10001/10002/10003 | 未提供 / 无效 / 过期 token | | 404 | 10005 | token 对应的用户不存在 | --- ## PUT /auth/email 用旧邮箱收到的验证码确认换邮箱。 **请求头**:`Authorization: Bearer ` **请求体**: ```json { "code": "123456", "new_email": "newaddress@example.com" } ``` | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | code | string | 长度必须为 6 | 旧邮箱收到的验证码 | | new_email | string | 合法邮箱格式;必须与 send-code 时提交的一致 | 新邮箱 | **成功响应**:返回更新后的完整 `UserResponse`(同 GET /auth/me)。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | code 长度不对 / new_email 不是合法邮箱格式 | | 400 | 10006 | 新邮箱已被其他账号使用(极少:并发竞争) | | 400 | 10010 | 验证码错误 | | 400 | 10011 | 验证码已过期(默认 15 分钟 TTL,由 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 控制)或从未请求 | | 401 | 10001/10002/10003 | 未提供 / 无效 / 过期 token | > **不一致检测**:如果用户在 send-code 时填的 new_email 是 `A@example.com`,但 PUT /auth/email 提交 `B@example.com`,会按 10010(验证码错误)拒绝——验证码绑定的是 send-code 当时的新邮箱。 --- ## PUT /auth/password 改密码。 **请求头**:`Authorization: Bearer ` **请求体**: ```json { "old_password": "oldpass123", "new_password": "newpass456" } ``` | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | old_password | string | — | 当前密码。**仅当账号已设密码时必填**(从其他平台迁移过来还没设密码的账号可留空) | | new_password | string | 6..100 字符 | 新密码 | **成功响应**: ```json { "code": 0, "message": "密码修改成功", "data": null } ``` > **不会自动登出其他设备**——其他 access_token 直到自然过期(15 分钟)才失效,refresh_token 直到自然过期(7 天)才失效。要立即下线所有设备需要走"撤销 refresh_token"流程(未来加),或 admin 手动 `DELETE /admin/users/:uuid/sessions`。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | new_password 长度不在 6..100 | | 400 | 10004 | 旧密码错误("邮箱或密码错误"——code 复用) | | 401 | 10001/10002/10003 | 未提供 / 无效 / 过期 token | | 404 | 10005 | 用户不存在 | --- 完整错误码表见 [04-tokens-and-errors.md](./04-tokens-and-errors.md#错误码速查)。 --- ## 跨服务批量查询 · /contracts/oauth/03-cross-service # 跨服务批量查询 返回 [README](./README.md) 服务到服务(kungal / moyu / galgame_wiki 等)批量拉用户公开资料的端点。这些下游服务**不在本地缓存** `users.name` / `users.avatar`,渲染时按 `user_id` 列表回拉。 **鉴权**:OAuth Client Basic Auth(`Authorization: Basic base64(client_id:client_secret)`),**不是**终端用户 JWT。任何已注册的 OAuth Client 都可以调用。 | 端点 | 方法 | 用途 | |------|------|------| | `/users/batch` | GET | 按 ID 列表批量拉公开资料 | | `/users/search` | GET | 按用户名子串搜索(@提及补全 / 检索框) | --- ## GET /users/batch 跨服务批量获取用户公开资料。 **查询参数**: | 参数 | 必填 | 说明 | |------|------|------| | ids | 是 | 1..100 个用户 ID(OAuth 用户表主键),逗号分隔,如 `?ids=1,2,3` | **成功响应**: ```json { "code": 0, "message": "成功", "data": { "users": [ { "id": 1, "uuid": "9e00220a-8079-4e81-8e98-49e26ce23edc", "name": "kun", "avatar": "https://image.kungal.com/avatar/user_1/avatar.webp", "avatar_image_hash": "abc123...", "bio": "KUN IS THE CUTEST!", "status": 0, "roles": ["admin"], "created_at": "2023-10-29T10:41:34Z" } ], "not_found": [9999] } } ``` | 字段 | 说明 | |------|------| | users[].id | 用户 ID(与 kungal/moyu 中 `*_user_id` 外键对齐) | | users[].uuid | 用户 UUID | | users[].name | 用户名 | | users[].avatar | 头像 URL(可能为空字符串) | | users[].avatar_image_hash | 头像 image_service 哈希(可空) | | users[].bio | 个人简介 | | users[].status | 0=正常;非 0 时调用方应隐藏或脱敏渲染 | | users[].roles | 角色名称数组,如 `["admin"]` | | users[].created_at | 用户 **OAuth 注册时间**,UTC RFC3339(如 `2023-10-29T10:41:34Z`)。渲染「注册 / 加入时间」**必须用此字段**——不要用下游本地行的 created(未登录过本站的用户根本没有本地行→空白;首次登录晚于注册的用户本地时间也是错的)。 | | not_found | 请求中存在但 OAuth 库里查不到的 ID 列表 | **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 9 | `ids` 为空或包含非数字 | | 400 | 9 | `ids` 个数超过 100 | | 401 | 10001/15001/15009 | Basic Auth 缺失/格式错/client_id 不存在/secret 错误 | **注意**:响应中**不包含** `email`、`moemoepoint` 等隐私字段(`created_at` 是公开的注册时间,**已包含**——见上表)。 若调用方需要邮箱(如发邮件通知),应该走专门的 RPC 而不是渲染管线。 **客户端实现**:OAuth 这边**不发布 SDK 代码**。每个 consumer 自己实现一个薄客户端(30 行起步,按工作负载需要加 TTL 缓存 / singleflight / 分片)。完整的实现指南、可直接复用的 Go 参考代码、以及决定层级的判断标准,见 [docs/migration/user/08-downstream-integration.md §4](../../migration/user/08-downstream-integration.md#4-客户端实现指南)。 --- ## GET /users/search 按用户名搜索用户,case-insensitive 子串匹配。结果按相关度排序:精确匹配 > 前缀匹配 > 子串匹配,每一档内按字母升序。 适用场景:@提及自动补全、用户搜索框、管理后台用户检索。 **鉴权**:与 `/users/batch` 相同(OAuth Client Basic Auth)。 **查询参数**: | 参数 | 必填 | 说明 | |------|------|------| | q | 是 | 搜索关键词,trim 后 1..50 字符。`%` `_` `\` 等 LIKE 通配符按字面匹配(已转义) | | limit | 否 | 返回条数,默认 20,封顶 50 | **成功响应**: ```json { "code": 0, "message": "成功", "data": { "users": [ { "id": 2, "uuid": "...", "name": "鲲", "avatar": "...", "bio": "...", "status": 0, "roles": ["admin"], "created_at": "2023-10-29T10:41:34Z" }, { "id": 79063, "uuid": "...", "name": "鲲1", ... }, { "id": 38359, "uuid": "...", "name": "鲲114514", ... } ] } } ``` **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 9 | `q` 为空或缺失 | | 400 | 9 | `q` 超过 50 字符 | | 400 | 9 | `limit` 不是正整数 | | 401 | 10001/15001/15009 | Basic Auth 缺失/格式错/凭证错 | > **注意**:搜索结果**不应缓存**(query 空间无界、结果随注册/改名漂移,缓存命中率低还容易出脏数据)。前端要做实时自动补全,调用方在前端 debounce(推荐 200–300ms)即可。 --- 完整错误码表见 [04-tokens-and-errors.md](./04-tokens-and-errors.md#错误码速查)。 --- ## Token 与错误码 · /contracts/oauth/04-tokens-and-errors # Token 与错误码 返回 [README](./README.md) --- ## JWT Access Token Claims ```json { "sub": "用户UUID", "email": "邮箱", "name": "用户名", "roles": ["admin", "ren"], "site_id": 0, "exp": 1700000000, "iat": 1699999100, "nbf": 1699999100 } ``` 签名算法:HS256 > **`roles` 是角色名的集合,普通用户为空数组 `[]`**(`user` 是隐式默认,**不会**出现在 claim 里)。角色及其能力语义的权威定义、以及下游必须遵守的规则,见 [11-roles.md](./11-roles.md)。 --- ## 错误码速查 所有错误的响应体格式都是: ```json { "code": , "message": "<中文消息>" } ``` `code = 0` 表示成功;其余按下面分类查阅。 ### OAuth 错误 (15xxx) | Code | HTTP | 消息 | 说明 | |------|------|------|------| | 15001 | 400 | 无效的客户端 | client_id 不存在 | | 15002 | 400 | 无效的回调地址 | redirect_uri 未注册 | | 15003 | 400 | 无效的授权码 | code 已过期 / 已使用 / 不存在 | | 15004 | 400 | 无效的代码验证器 | PKCE code_verifier 不匹配 | | 15005 | 400 | 无效的授权类型 | client 的 `grants` 列里没有当前 grant_type — **常见:admin 创建 client 时漏勾 `refresh_token`** | | 15006 | 400 | 无效的权限范围 | 请求的 scope 不在 client 的 `allowed_scopes` 内 | | 15007 | 400 | 访问被拒绝 | 用户拒绝授权 | | 15008 | 400 | 无效的 client secret | confidential client 没传或填错 client_secret | | 15009 | 400 | 需要 PKCE | public client 没传 code_verifier | ### 认证错误 (10xxx) | Code | HTTP | 消息 | 说明 | |------|------|------|------| | 10001 | 401 | 未授权 | 未提供 Bearer Token | | 10002 | 401 | 无效的令牌 | Token 格式错误 / 签名无效 / **refresh 时 client_id 与签发时的不匹配** | | 10003 | 401 | 令牌已过期 | access_token 或 refresh_token 已过期,需要刷新或重新登录 | | 10004 | 400 | 邮箱或密码错误 | 登录或 `PUT /auth/password` 时旧密码不匹配 | | 10005 | 401 | 用户不存在 | UUID 对应的用户不存在(账号被硬删等罕见情况) | | 10006 | 400 | 该邮箱已被注册 | 改邮箱 / 注册(send-code 或 register 阶段)时邮箱已存在 | | 10007 | 400 | 用户名已存在 | PATCH /auth/me 改 name / 注册时用户名重复 | | 10010 | 400 | 验证码无效 | 改邮箱 / 注册时 6 位码错误(或与 send-code 时提交的 email 不一致) | | 10011 | 400 | 验证码已过期 | 验证码过期(默认 15 分钟,由 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 控制)或从未请求;常见来源:用户修改了 email 后没重发码 | | 10012 | 400 | 邮箱验证码发送过于频繁 | 上一次 send-code 距今不足限流间隔(改邮箱按 user 限流 / 注册按 email 限流) | | 10013 | 400 | 新邮箱与当前邮箱相同 | 改邮箱 send-code 时检测;不消耗验证码额度 | | **10014** | **403** | **账号已封禁** | 用户被 admin 封号 — **前端应跳错误页("账号被封禁")而非登录页**,让用户再登也是同样的 403 | ### 通用错误 | Code | 消息 | 触发场景示例 | |------|------|----| | 1 | 请求格式错误 | JSON 语法错 / multipart 解析失败 / 上游服务(如 image_service)报错 | | 7 | 参数验证失败 | 字段长度 / 格式校验未通过 | | 8 | 缺少必要参数 | 必填字段没传(如 multipart 缺 `file`) | | 9 | 参数无效 | 类型不对 / 超出范围(如 `ids` 个数 >100、`limit` 不是正整数) | | 10 | 操作失败 | 一般是 DB 写入失败、外部服务异常等内部错误 | --- ## 用户注册 · /contracts/oauth/05-registration # 用户注册 返回 [README](./README.md) > **重要:注册是身份层操作。下游 kungal / moyu / wiki 不应在自己前端做注册表单**——和[改邮箱 / 改密码一样](./02-user-profile.md#身份操作-vs-展示操作)。本文档描述统一的"跳转到 OAuth 注册 → 自动回跳并登录"流程。 ## 整体设计 ``` ┌──────────┐ ┌─────────────────────┐ │ Kungal │ ① 用户点"注册" → window.location │ oauth.kungal.com │ │ /moyu / │ ──────────────────────────────► │ /auth/register │ │ wiki │ ?redirect= │ ?redirect=... │ └──────────┘ └────────┬────────────┘ ▲ │ │ │ ② 填 name/email/password → │ │ POST /auth/register/send-code │ │ ↓ 后端寄 6 位码到邮箱 │ │ │ │ ③ 用户从邮箱取码 → 填进表单 → │ │ POST /auth/register { ..., code } │ │ (后端验码 + 建账号 + │ │ 发 access_token + refresh cookie) │ ▼ │ ┌─────────────────────┐ │ │ oauth.kungal.com │ │ │ /oauth/authorize │ │ │ ?client_id=... │ │ ⑥ /auth/callback?code=... │ &state=...&PKCE=... │ │ ← exchange → 本站 session 创建 └────────┬────────────┘ │ │ │ │ ④ 用户已登录(注册时拿到了 access_token) │ │ + client.auto_consent === true │ │ ⇒ Container.vue 不渲染同意 UI, │ │ 直接 POST /oauth/authorize/consent │ ▼ │ ┌─────────────────────┐ │ │ oauth backend │ │ │ issues code │ └──────────────────────────────────────────│ 302 → redirect_uri │ ⑤ └─────────────────────┘ ``` 整条链路是**注册 + OAuth code 流程合一**:用户先通过邮箱验证码证明邮箱所有权,OAuth 后端验码通过后才创建账号并发 token。注册成功后 OAuth web 复用 `?redirect=` 直接跳到 `/oauth/authorize`,由于 client 是第一方(`auto_consent=true`),同意页跳过,code 立刻发回 kungal,kungal 用现成的 OAuth callback 流程完成本站登录。**用户感知是"点注册 → 填表单 → 收码 → 回到原站点已登录"**,中间 OAuth 域名的存在被淡化到一闪而过。 > **为什么强制邮箱验证**:注册是身份创建动作,邮箱不被验证就建账号 = 任何人都能用别人邮箱去抢注 + 后续找回密码邮件会发到无效地址。两步验证 = 邮箱所有权证明 + 反垃圾注册。同款模式见[改邮箱](./02-user-profile.md#post-authemailsend-code)。 ## 为什么不在 kungal/moyu 自己前端做注册 和[身份层政策](./02-user-profile.md#身份操作-vs-展示操作)一致: - **唯一身份写入入口**:用户表只能从 OAuth 这边写。N 个下游各自实现注册 → N 套验证码 / 限流 / 反爬 / 邮箱去重逻辑 → N 个攻击面 - **未来收益自动化**:将来加 passkey / magic link / 第三方登录 / 异地通知,只改 OAuth 一处,所有下游零代码受益 - **和登录对齐**:登录已经走 OAuth Authorization Code + PKCE(kungal / moyu 已迁移),注册同样走 OAuth 是自然的对称——一个登录入口 + 一个注册入口都在身份提供方 - **政策一致性**:改密码 / 改邮箱必须在 OAuth profile,注册当然也应该在 OAuth 下游唯一要做的是"注册"按钮的跳转 URL —— 和登录按钮共享同一段 PKCE 生成代码即可。 --- ## 端点 ### POST /auth/register/send-code **两步注册的第一步**:把 6 位数字验证码寄到 `email`,15 分钟(默认,可通过 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 调整)有效。 **请求体**: ```json { "name": "kun", "email": "kun@kungal.com" } ``` | 字段 | 类型 | 约束 | |---|---|---| | name | string | 1..17 字符;允许 Unicode 字母/数字 + `!~_@#$%^&*()+=-`;**禁止**所有不可见 Unicode 字符(零宽、特殊空格、BOM 等 50+ 种)—— 详见 `utils.IsValidName` | | email | string | 合法邮箱格式 | **行为**: - 同步预检:name / email 是否已被注册 → 已被注册立即返回错误(不烧验证码额度,不发邮件) - 同邮箱限流:15 分钟(默认,可通过 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 调整)内同一邮箱最多寄 1 次(Redis key `register_code:{email}` 兜底) - 验证码 6 位数字,存 Redis 15 分钟(默认,可通过 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 调整) TTL - 调 SMTP(`auth@kungal.com`)寄正式邮件,模板见 [`mail.go SendRegisterCodeEmail`](../../../apps/api/internal/infrastructure/mail/mail.go) **成功响应**: ```json { "code": 0, "message": "验证码已发送到该邮箱", "data": null } ``` **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | 字段约束未通过(name 长度 / email 格式) | | 400 | 10006 | 邮箱已被注册 | | 400 | 10007 | 用户名已被使用 | | 400 | 10012 | 该邮箱 15 分钟(默认,可通过 `KUN_AUTH_VERIFICATION_CODE_TTL_MINUTES` 调整)内已发过验证码(限流) | | 429 | — | 同 IP 触发 strict 限流(10/分钟) | > **错误码 10006 + 10007 会泄露"该邮箱/用户名是否已注册"** —— 这是有意权衡:用户体验上需要明确告知"换一个吧",而注册场景的账号枚举攻击面比登录小(登录页只回笼统的"账号或密码错误")。如果产品需要更严的反枚举,未来可改为"如果该邮箱可注册,验证码已寄出"统一文案 + 后端静默吞错。 --- ### POST /auth/register **两步注册的第二步**:用上一步收到的验证码 + 完整凭证创建账号,并立即发放 token(**注册即登录**)。 **请求体**: ```json { "name": "kun", "email": "kun@kungal.com", "password": "secret123", "code": "123456" } ``` | 字段 | 类型 | 约束 | |---|---|---| | name | string | 1..17 字符;全局唯一;字符集见 send-code 节(`utils.IsValidName`) | | email | string | 合法邮箱格式;全局唯一;**必须与 send-code 时一致**(验证码按 email key 存的) | | password | string | 6..100 字符 | | code | string | 6 位数字;从 send-code 时寄到 email 的邮件正文中获取 | **成功响应**:返回访问令牌 + 用户资料 + 刷新令牌(写 httpOnly cookie)。 ```json { "code": 0, "message": "成功", "data": { "access_token": "eyJhbGc...", "user": { "uuid": "...", "name": "kun", "email": "kun@kungal.com", "avatar": "", "bio": "", "moemoepoint": 0, "status": 0, "roles": [], "created_at": "2026-05-23T08:00:00Z" } } } ``` `refresh_token` 写在 httpOnly cookie 里(`Path=/api/v1/auth`,7 天),调用方不需要也不应处理。注册成功后 Redis 中的验证码立即被删除,**不可重放**。 **错误响应**: | HTTP | code | 触发条件 | |------|------|----------| | 400 | 1 | JSON 格式错误 | | 400 | 7 | 字段约束未通过(name/email/password/code 长度或格式) | | 400 | 10006 | 邮箱已被注册(并发竞争;正常 send-code 阶段就该挡掉) | | 400 | 10007 | 用户名已被使用(同上) | | 400 | 10010 | 验证码错误(不匹配 send-code 时存的值) | | 400 | 10011 | 验证码已过期或从未请求(Redis 里没找到这个邮箱的 code) | | 429 | — | 同 IP 触发 strict 限流(10/分钟) | > **常见 10011 来源**:用户改了 email 字段后没重新发验证码 → 后端按新 email 找 Redis key 找不到 → 报"已过期"。前端应当锁定 email 字段直到用户主动点"重新发送"。 **调用方**:**只有 oauth.kungal.com 自己的前端应该直接调这两个端点**。下游 kungal / moyu / wiki 应该走"跳转到 oauth.kungal.com/auth/register"的模式(见下方"下游接入")。 --- ### GET /oauth/client-info 公开元数据查询。无鉴权。供前端在 `/oauth/authorize` 页面**判断是否跳过同意 UI** 时调用。 **查询参数**: | 参数 | 必填 | 说明 | |---|---|---| | client_id | 是 | OAuth client ID | **成功响应**: ```json { "code": 0, "data": { "id": "4ed9bc99ec0a789a4796b83e22bd84c5", "name": "鲲 Galgame 论坛", "auto_consent": true, "site_domain": "www.kungal.com" } } ``` | 字段 | 说明 | |---|---| | id | client_id(回显) | | name | 展示名 | | auto_consent | 第一方 client 标志;为 true 时前端**跳过同意页**,直接 POST `/oauth/authorize/consent` | | site_domain | 关联 site 的 domain(可空),用于展示"将跳转回 X" | > **不返回**:`secret`、`redirect_uris`、`scopes` 等敏感 / 实现细节。这个端点只为前端判定 UI 行为服务。 **错误响应**: | HTTP | code | 触发条件 | |---|---|---| | 400 | 2 | 缺 client_id | | 404 | 15001 | client_id 不存在 | --- ## 下游接入 ### 1. 注册按钮(kungal / moyu / 任何下游) 和**登录按钮共用同一段 PKCE 代码**,只把目标路径从 `/oauth/authorize` 换成 `/auth/register?redirect=`。 ```ts // kungal/apps/web/app/components/register/Register.vue (示意) const handleOAuthRegister = async () => { const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) const state = generateState() sessionStorage.setItem('oauth_code_verifier', codeVerifier) sessionStorage.setItem('oauth_state', state) const authorizeParams = new URLSearchParams({ client_id: config.public.oauthClientId, redirect_uri: config.public.oauthRedirectUri, response_type: 'code', scope: 'openid profile', state, code_challenge: codeChallenge, code_challenge_method: 'S256' }) // 注册成功后 OAuth web 会跳到这里 const authorizeUrl = `${config.public.oauthServerUrl}/oauth/authorize?${authorizeParams}` // OAuth 注册页 URL;redirect 参数让注册完后串到 authorize 流程 const registerUrl = `${config.public.oauthWebUrl}/auth/register?redirect=${encodeURIComponent(authorizeUrl)}` window.location.href = registerUrl } ``` 注意 `oauthWebUrl` 是前端域名(开发 `:9420` / 生产 `oauth.kungal.com`),`oauthServerUrl` 是 API 域名——两者可能不同。详见 [oauth-integration-guide.md §1.3](./oauth-integration-guide.md#13-oauth-server-地址)。 ### 2. 用户感知的完整时间线 | 步 | 用户看到的 URL | 时间 | 用户感知 | |---|---|---|---| | 1 | `www.kungal.com/login` 点击"注册" | 0 ms | 点击 | | 2 | `oauth.kungal.com/auth/register?redirect=...` | ~200 ms | "跳到了账号注册页" | | 3 | 同上,填表 | 用户自主时间 | 填邮箱 + 密码 + 用户名 | | 4 | `oauth.kungal.com/oauth/authorize?...` | ~100 ms(注册返回后立即跳) | **白屏一闪**(auto_consent 不渲染 UI) | | 5 | `www.kungal.com/auth/callback?code=...` | ~150 ms | **白屏一闪**(kungal 在交换 token) | | 6 | `www.kungal.com/` (或 redirect_uri 配的路径) | — | "我已经登录了" | 第 4 步和第 5 步加起来一般在 300 ms 以下,用户感知就是"注册完成后回到原站点已登录"。 ### 3. 已注册用户访问 `/auth/register` 的处理 用户已登录的状态下访问 `oauth.kungal.com/auth/register?redirect=...`,OAuth web 应当: - 如果 `redirect` 参数存在 → 立即 `window.location.href = redirect`(推进 OAuth code 流程) - 否则 → 跳 `/profile`(账号管理页) **绝不应该**让已登录用户看到一个空注册表单——会引发"我已经登录了为什么让我再注册一次"的困惑。 --- ## auto_consent 字段语义 `oauth_clients.auto_consent` boolean,默认 `false`。**为 true 表示这个 client 在 `/oauth/authorize` 流程中跳过用户同意 UI**——前端不渲染"该应用将获得以下权限"卡片,直接 POST `/oauth/authorize/consent`。 **何时设 true**: - **设 true**:第一方 client(owner 是 OAuth 平台自己,比如 kungal / moyu / wiki / AI / sticker) - **保持 false**:第三方接入应用 - **保持 false**:任何不在你直接控制下的 client **安全模型**:auto_consent 不是降低安全等级,是承认"用户已经在使用 kungal,不需要再问一次 kungal 是否可以读他的 OAuth 资料"。这是 SSO 的标准做法——Google 内部应用之间也不会重复问同意。 **当前的第一方列表**(auto_consent=true): | client_id | name | site | |---|---|---| | 4ed9bc99ec0a789a4796b83e22bd84c5 | 鲲 Galgame 论坛 | www.kungal.com | | df3ff6008d740bfacbe46aa8cf483cf2 | 鲲 Galgame 补丁 | www.moyu.moe | | 53e9b5ea70bfc4e4d0700a9f7b8818e8 | 鲲 Galgame Wiki | wiki.kungal.com | | df46a4cfa71ac919b7b43d63238e2311 | 鲲 Galgame AI | ai.kungal.com | | 2d8d48a141a3340b43ae206b73cdaa37 | 鲲 Galgame 表情包 | sticker.kungal.com | 如果将来接入第三方应用(比如某社区合作伙伴),新建的 client 默认 `auto_consent=false`,会渲染同意页让用户明确授权——这是 OAuth 协议的正确语义。 --- ## 未来扩展(L2+) **L2(未来)**:在 `/auth/register` 和 `/auth/login` 页面加 "Continue with Google / GitHub / Apple" 按钮,走标准 OIDC federation。**所有下游零代码自动支持**——这是 OAuth 集中架构最大的红利。 **L3+(待定)**:passkey / magic link / identifier-first flow 等,都在 OAuth 单点实现,下游不感知。 --- ## 不在范围内(明确排除) - 邀请码 / 内测注册——目前不限制 - 手机号注册——目前邮箱-only - 用户名 vs 邮箱选择——目前 name + email 都必填 - 二次邮箱验证后才能登录——注册即登录,邮箱验证留给后续防滥用迭代 - ToS / 隐私政策点击确认——目前没有,加的话只在 OAuth web 这一处加 这些都是 L2+ 议题;L1 只做"注册流程从 legacy 完全迁移到 OAuth 托管"。 --- ## 06 — 萌萌点(moemoepoint)统一货币(精简版) · /contracts/oauth/06-moemoepoint # 06 — 萌萌点(moemoepoint)统一货币(精简版) 返回 [README](./README.md) > **状态:已实现**。`moemoepoint_log` 表 + s2s 端点 `POST/GET /users/:id/moemoepoint`(`Adjust` 幂等 / `GetBalance`)+ 用户自助 `GET /auth/me/moemoepoint/log` 均已上线。实现见 `internal/platform/auth/handler/moemoepoint_handler.go`,路由注册见 `cmd/oauth/main.go`。 ## 0. 决策与定位 - **moemoepoint 全站统一**:一个用户在 kungal / moyu / 未来所有接入站点**共享一个余额**,**单一真源在 OAuth**(共享身份库)。 - 本设计**刻意精简**:萌萌点是软性 karma(非货币、低频写入、出错最坏只是"数字不对",不涉资损)。所以只保留三个真正有价值的属性 —— **幂等、审计、单源** —— 砍掉金融账本级的严谨度和多团队治理(详见 §9 与早期完整版的差异)。 ## 1. 数据模型 ### 1.1 余额:`users.moemoepoint`(可变列,保留) 仍是一个普通可变整型列,作为**当前余额**。每次调整在**同一事务**里 `moemoepoint += delta`。不把它变成"日志的派生值"——对 karma 来说没必要。 ### 1.2 审计日志:`moemoepoint_log`(只追加,不改不删) ```sql CREATE TABLE moemoepoint_log ( id BIGSERIAL PRIMARY KEY, user_id INTEGER NOT NULL, delta INTEGER NOT NULL, -- 有符号,非 0 reason VARCHAR(40) NOT NULL, -- 见 §2 小枚举 source_app VARCHAR(32) NOT NULL, -- 来源站点(服务端从认证 client 推导) ref VARCHAR(80), -- 触发实体,自由格式如 "galgame:1207"(可空) actor_user_id INTEGER NOT NULL DEFAULT 0, -- 谁导致:0=系统 / 管理员 id idempotency_key VARCHAR(128) NOT NULL, -- 防重放,全局唯一 note VARCHAR(255), -- 备注(管理员操作填) created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX idx_mp_log_idem ON moemoepoint_log (idempotency_key); CREATE INDEX idx_mp_log_user ON moemoepoint_log (user_id, id DESC); CREATE INDEX idx_mp_log_reason ON moemoepoint_log (reason); -- 分类查看 ``` - 日志**只追加**:发错了写一条反向记录(`delta` 取负),不改旧行。 - "分类查看" = 按 `reason`(或 `source_app`)过滤 / 聚合。 ## 2. reason(一张小而稳的通用枚举,OAuth 拥有) 扁平、通用、少。具体业务细节靠 `source_app` + `ref` 区分,**不为每个站点维护各自的枚举**(那是被砍掉的治理层)。 | reason | 方向 | 说明 | |---|---|---| | `admin_grant` / `admin_deduct` | ± | 管理员发放 / 扣除 | | `migration` | + | 迁移起始值(§6)| | `register_gift` | + | 注册欢迎礼(OAuth 注册成功时一次性发放,note=「鲲给予你的第一份礼物」);OAuth 内部,s2s 不可用 | | `content_approved` | + | 产出被采纳(Wiki 投稿通过、补丁发布…,用 source_app+ref 区分)| | `content_removed` | − | 上述产出被删 / 撤回时回收(与发放同 `ref`)| | `daily_checkin` | + | 每日签到 | | `liked` | + | 内容被点赞 | 约定:`delta` 禁止为 0;可回收的产出,回收用相同 `ref` 对账。新增一种来源 = 往这张表加一行(你一个人控制全部 client,一次小改即可,无需治理协调)。 ## 3. 服务到服务 API **鉴权**:与 [`/users/batch`](./03-cross-service.md) 相同 —— **OAuth Client Basic Auth**。`source_app` 服务端从认证 client 推导,不信任请求体自报。 **铸币白名单(POST 专属,2026-06-13 新增)**:**写入**(`POST` 调整余额)额外要求该 client `oauth_clients.moemoepoint_awarder = true`。萌萌点是**全生态共享的单一钱包**,只有合法发放方(论坛 / 补丁)在白名单内;其它任何已注册 client 默认 **fail-closed**(`awarder=false`),POST 返回 `403 / 16005`。**读取**(`GET` 余额 / 流水)不受影响,对任意已注册 client 开放。 > **为什么要白名单**:一个定位不同的站点(例如成人向资源站 letmoe)可能**读取**用户余额来**一次性 1:1 初始化自己的本地积分**——这没问题;但它**绝不能往共享钱包铸币**,否则会把自己的 provenance 戳进每个用户的全生态流水。"读取做种"放行,"铸币"按 **parent Site.Domain** 显式授权(`cmd/migrate` 按 `www.kungal.com` / `www.moyu.moe` 幂等回填,不硬编码 per-env `client_id`)。新增一个合法发放方 = 往该域名列表加一行;新增一个只读站点 = 什么都不用做(保持 fail-closed)。 | 端点 | 方法 | 用途 | |------|------|------| | `/users/:id/moemoepoint` | POST | **调整**余额(发放 / 扣除),幂等 | | `/users/:id/moemoepoint` | GET | 读当前余额 | | `/users/:id/moemoepoint/log` | GET | 分页拉流水(可选;用户"积分明细" / 排查)| ### 3.1 POST /users/:id/moemoepoint ```json { "delta": 3, "reason": "content_approved", "ref": "galgame:1207", "actor_user_id": 0, "idempotency_key": "moyu:wiki_approved:1207", "note": "" } ``` | 字段 | 必填 | 说明 | |------|------|------| | delta | 是 | 有符号整数,非 0,且 \|delta\| ≤ 1,000,000(防呆上限)| | reason | 是 | §2 枚举之一。**s2s 不可用** `admin_grant` / `admin_deduct` / `migration` / `register_gift`(OAuth 保留)| | ref | 否 | 触发实体(建议填,用于对账)| | actor_user_id | 否 | 默认 0(系统);管理员操作填管理员 id | | idempotency_key | 是 | 全局唯一,**调用方生成稳定键**(见 §4)| | note | 否 | 备注 | **成功响应**(首次执行 与 幂等重放 一致): ```json { "code": 0, "message": "成功", "data": { "user_id": 1207, "balance": 42, "applied": true } } ``` `applied=false` 表示幂等键命中、未重复执行。 **错误**(HTTP 400 + 对应 code,除非另注):`16002` delta 为 0 或超 ±1,000,000;`16003` reason 非法 / 用了保留 reason;`16004` 幂等键已存在但请求体不一致;`403/16005` client 不在铸币白名单(`moemoepoint_awarder=false`);`404/10005` 用户不存在;`401` Basic Auth 失败。 > 余额**允许为负**(精简取舍:不做非负约束,保证回收/反转永不被挡)。 ### 3.2 读取 - `GET /users/:id/moemoepoint` → `{ "balance": 42 }`。 - `GET /users/:id/moemoepoint/log?limit=20&before_id=&reason=` → 分页流水(`reason` 可选过滤)。**s2s 返回精简视图**:`{ id, delta, reason, source_app, ref, created_at }`,**不含** `note` / `actor_user_id`(这俩可能含管理处罚备注,下游可能渲染给终端用户,故不下发;管理端 `/admin/.../log` 返回完整视图)。 - 也可在 `/auth/me` / userinfo 里直接返回 OAuth 的实时余额(替掉现在的冻结快照)。 - **自助流水**:`GET /auth/me/moemoepoint/log?limit=&before_id=&reason=`(**用户 JWT**,`Auth` 鉴权,id 取自 token 非路径参——避免越权读他人)。返回与 s2s 同口径的**精简视图**(无 `note` / `actor_user_id`)。OAuth web 端 `/profile`「萌萌点记录」直接用它;下游站点若不想自己代理 s2s 端点,用户也可直连此端点查自己的流水。 ## 4. 幂等(唯一需要严谨的点) 下游发放常由会重试 / 重放的路径触发(典型:moyu cron「Wiki 消息 → +3」),没有幂等就会重复加分。 - 调用方为**每个业务事件**生成**稳定**键,推荐 `::<事件唯一id>`,如 `moyu:wiki_approved:1207`、`kungal:checkin:1207:2026-05-29`。 - 服务端:`idempotency_key` 唯一索引。已存在 → 不重复执行,回原结果(`applied:false`)。请求体不一致 → `400/16004`。 - 写入在单事务内对该用户行加锁(`SELECT … FOR UPDATE`)防并发竞态;唯一索引兜底。 ## 5. 管理端 - 发放 / 扣除走**同一个 Adjust 入口**(`reason=admin_grant`/`admin_deduct`、`actor_user_id=管理员id`、`note` 填理由、幂等键用表单 token)→ 自动进同一审计日志。 - OAuth admin(用户管理页)加:发放/扣除弹窗 + 流水查看。 - **不要**给管理员开"直接编辑整型"的口子(绕过日志)。 ## 6. 迁移(一次性) 1. 下游停止本地 `moemoepoint` 写入(或短期双写过渡)。 2. 每个用户取各站本地值之和作为统一起始余额。**已落地**:`cmd/migrate-users` 在 ID 统一时已把 kungal + moyu 的本地值累加进 `users.moemoepoint`。 3. 写一条 `reason=migration` 日志(`idempotency_key=oauth:migration:v1:`,可重复跑)。**已落地**:`go run ./cmd/migrate-moemoepoint`(支持 `-dry-run`)为每个有余额却无流水的用户回填一条 `reason=migration`、`note=「从 鲲 Galgame 论坛 和 鲲 Galgame 补丁 继承」` 的记录;delta = 当前余额 − 已有流水之和(取“未被解释的余额”,使账本严格对账),**不改 `users.moemoepoint`**(已是正确值),幂等可重跑。 4. 下游删本地写逻辑,改:发放/扣除调 §3.1;显示余额回读 OAuth。 > 注册欢迎礼:新账号在 OAuth 注册成功时自动 +7(`reason=register_gift`,`note=「鲲给予你的第一份礼物」`,`idempotency_key=oauth:register_gift:`,best-effort 不阻塞注册)。这是 OAuth 内部一次性发放,与上面的 `migration` 互不影响(新用户的余额由 `register_gift` 这条流水解释,不会被 §6 回填)。 ## 7. 下游接入 | 现在 | 改成 | |---|---| | 本地 `UPDATE user SET moemoepoint = moemoepoint + N` | 调 `POST /users/:id/moemoepoint`(Basic Auth + 稳定幂等键)| | cron 重放发放 | 同上,幂等键用业务事件唯一 id → 重放安全 | | 渲染余额读本地列 | 读 OAuth 实时余额(`/auth/me` 或 §3.2)| > **可用性注意**:发放现在依赖 OAuth 可达。对**非关键**奖励(签到、点赞),调用失败应"记录待补 + 不阻塞用户主流程",靠幂等键之后重试补发;不要让 OAuth 抖动卡住下游核心操作。 OAuth **不发布 SDK**,每个 consumer 自己写薄客户端(同 `/users/batch` 的 Basic Auth)。 ## 8. 错误码(16xxx) | code | 常量 | 含义 | |---|---|---| | 16002 | `ErrMoemoepointInvalidDelta` | delta 为 0 或 \|delta\| > 1,000,000 | | 16003 | `ErrMoemoepointInvalidReason` | reason 不在枚举内,或 s2s 用了保留 reason(admin_*/migration)| | 16004 | `ErrMoemoepointIdemConflict` | idempotency_key 已存在但请求体不一致 | | 16005 | `ErrMoemoepointNotAwarder` | client 无铸币权限(`moemoepoint_awarder=false`,仅 POST 调整,HTTP 403)| > 已实现状态:上述 4 个码 + `moemoepoint_log` 表 + s2s/admin 端点 + 管理端 UI **均已落地**(待 oauth 后端重启生效)。铸币白名单(`16005` + `moemoepoint_awarder` 列 + `cmd/migrate` 按域名回填论坛/补丁)于 2026-06-13 落地。下游消费 + 数据合并迁移(§6/§7)仍待各站对接。 > 并发同键竞态:唯一索引兜底(不会重复加分),极少数并发同键会得到一次性 500,调用方重试即转为 `applied:false`。 ## 9. 刻意没做的(将来需要时再升级) 为对齐当前规模(~9 万用户的爱好社区、单人维护、karma 非货币),以下**故意省略**——等真有需求再加: | 砍掉的 | 完整账本版才需要 / 何时再加 | |---|---| | 余额 = `SUM(delta)` 派生 + `balance_after` 快照 + 定时对账巡检 | 资损级系统 / 需要逐行可证一致性时 | | 两级 `category` 闭集 + `reason` 命名空间防伪 + per-app reason 清单模板 | 出现**多个独立团队**各自定义大量积分玩法、需要解耦发版时 | | per-client category 白名单、单次/单日累计上限 | 萌萌点可兑换实物、出现真实刷分/欺诈动机时 | 升级是可逆且渐进的:日志表已记 `reason`/`source_app`/`ref`,将来要分两级或加约束都能在现有数据上演进,不必现在预付复杂度。 --- ## 07 — 登出与单点登出(RP-Initiated Logout) · /contracts/oauth/07-logout # 07 — 登出与单点登出(RP-Initiated Logout) 返回 [README](./README.md) > 本节是**跨服务契约**:下游(kungal / moyu / wiki 等 RP)必须按此实现登出,否则会出现「登出后再点登录/注册,直接静默登回刚才的账号」的回归。 ## 背景与症状 集中式 SSO 下,`oauth.kungal.com` 是 OP(身份提供方),各站是 RP(接入方)。用户反馈:**在 wiki / 补丁站登出后,点「登录」或「注册」会直接以刚才的账号登入,没有任何提示。** ## 根因:RP 登出 ≠ OP 登出 OP 的「登录态」由两样东西决定,**都在 `oauth.kungal.com` 这个 origin 上**: 1. OP 前端 `localStorage` 里持久化的 `user`(驱动 `isLoggedIn`); 2. `refresh_token` httpOnly cookie + DB `sessions` 行。 RP 登出只清掉了**那个站自己**的状态。而: - **`localStorage` 严格按 origin 隔离** —— 任何 RP 都无法清除 `oauth.kungal.com` 的 `localStorage`,OP 前端的 `user` 始终还在 → `isLoggedIn` 恒为真。 - 跨站(如补丁站 `touchgal.moe` → `oauth.kungal.com`)的 `SameSite=Lax` refresh cookie 连后台 fetch 都带不过去 → cookie / 会话也清不掉。 于是再点登录跳到 `/oauth/authorize` 时,OP 前端判定「已登录」+ 客户端 `auto_consent=true` → **静默发码** → 登回原账号。 **关键结论:要清掉 OP 会话,浏览器必须顶层导航到 `oauth.kungal.com`** —— 只有真正访问到该 origin,才能同时清掉它的 cookie/会话**和** `localStorage`。后台 fetch 两样都做不到。这正是 [OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) 解决的问题。 ## 方案:RP 登出时顶层跳转到 OP 登出入口(单点登出) 对同主人的一方生态,「登出 = 退出整个鲲 SSO」是最自然的语义。流程: ``` 用户在 RP 点登出 → RP 清本地会话 → 浏览器顶层跳转 {OAUTH_API_BASE}/oauth/logout?client_id=&redirect=<回到RP的地址> → 后端 302 跳到 OP 前端登出页 /auth/logout(对称于 /oauth/authorize 跳同意页) → OP 前端页清 refresh cookie + DB session + OP localStorage 的 user → 校验 redirect 在白名单 → 跳回 RP → 用户回到 RP(此时 OP 会话已清;再点登录会要求重新登录) ``` ## OP 端点契约 ### 1. 登出入口(顶层导航,对称于 /oauth/authorize) ``` GET {OAUTH_API_BASE}/oauth/logout?client_id=&redirect= # 生产: https://oauth.kungal.com/api/v1/oauth/logout?client_id=...&redirect=... ``` - **RP 必须用 `window.location.href`(浏览器顶层导航)跳过来,不能用 fetch/XHR** —— fetch 清不掉 OP 的 `localStorage`/cookie(见上文根因)。 - 这是**后端入口**,会 302 跳到 OP 前端登出页 `/auth/logout`(与 `/oauth/authorize` 跳到前端同意页同一模式)。前端页执行:清 OP 会话(`POST /api/v1/auth/logout` 删 session + 清 refresh cookie)+ 清 OP 前端 `localStorage` 的 `user` 与 `access_token` cookie → 校验 `redirect` → `window.location` 跳回。 - RP 复用访问 `/oauth/authorize` 时用的同一个 `OAUTH_API_BASE`(含 `/api/v1`),无需额外配置 OP 前端域名;dev(前后端不同源)下也能正确解析(后端用 `cfg.Server.FrontendURL` 找前端页)。 | 参数 | 必填 | 说明 | |------|------|------| | `client_id` | 是 | 发起登出的 RP 客户端 ID(用于校验 `redirect` 白名单)| | `redirect` | 否 | 登出后回跳地址(post-logout redirect URI)。缺省 / 校验不过 → 跳 OP 首页 | ### 2. 回跳白名单校验(OP 登出页内部用) ``` GET /api/v1/oauth/post-logout-redirect?client_id=&redirect= ``` 无需鉴权。校验 `redirect` 的 **origin(scheme+host)是否匹配该 client 注册的任一 `redirect_uri` 的 origin**,匹配则回显,否则回空。 **成功响应**: ```json { "code": 0, "data": { "url": "https://www.moyu.moe/?logged_out=1" } } ``` `data.url` 为空字符串表示 `redirect` 不在白名单(防 open-redirect),调用方应回退到安全默认地址。 > 白名单口径:复用 client 已注册的 `redirect_uris` 的 origin(一方生态下,能作回调目标的 origin 即可信作登出回跳目标)。如需更严格,后续可加独立的 `post_logout_redirect_uris` 注册项。 ### 3. `prompt=login`(强制重新登录,可选的「仅登出本站」语义) `GET /oauth/authorize` 新增可选查询参数 `prompt`: | 值 | 行为 | |----|------| | `login` | 即使 OP 仍有会话,也**强制显示登录界面**(不走 auto-consent 静默放行)| | (空)| 默认行为(有会话 + auto_consent → 静默发码)| 适用「只想登出本站、但本站再登要重新确认」的 RP:登出时**不**做全局单点登出,而是在下次发起 authorize 时带上 `prompt=login`。与方案 1(单点登出)二选一即可。 ## 下游(RP)接入步骤 **单点登出(推荐)**——把登出按钮改成顶层跳转: ```ts // RP 登出处理 const logout = async () => { // 1. 清本站自己的会话(清 token / 本地 store / 调用本站后端登出) await clearLocalSession() // 2. 顶层跳转到 OP 登出入口,登出后回到本站。 // OAUTH_API_BASE = 你访问 /oauth/authorize 用的同一个 base(含 /api/v1)。 const back = encodeURIComponent(window.location.origin + '/') window.location.href = `${OAUTH_API_BASE}/oauth/logout?client_id=${MY_CLIENT_ID}&redirect=${back}` } ``` **注册要求**:回跳地址(`redirect`)的 origin 必须与该 client 注册的某个 `redirect_uri` 同源。例如 `redirect_uri = https://www.moyu.moe/auth/callback`,则 `redirect = https://www.moyu.moe/...` 合法。 > 注意:不要只在前端「清得更干净」就完事 —— `localStorage` 跨 origin 清不掉 OP 的,必须顶层跳转到 OP 登出入口。 ## 与既有端点的关系 | 端点 | 作用 | 与本节关系 | |------|------|-----------| | `POST /api/v1/auth/logout` | 删当前 session + 清 refresh cookie(需 Bearer)| OP 登出页内部调用它清后端会话 | | `POST /api/v1/oauth/revoke` | RFC 7009 吊销指定 token | S2S 吊销;不清浏览器 OP 前端状态,**不能**替代登出入口 | | `GET /api/v1/oauth/logout`(后端入口)| **本节新增**:302 跳到 OP 前端登出页 | RP 登出的正确入口(顶层导航)| | `GET /auth/logout`(OP 前端页)| **本节新增**:清 OP 会话 + 校验回跳 | 由上面的入口 302 进来;RP 一般不直接跳这个 | ## 安全 - **Open-redirect 防护**:`redirect` 必须通过白名单校验(origin 匹配注册的 `redirect_uri`),否则回退安全默认地址。 - 登出入口对未登录访问也安全(无会话可清时直接按白名单回跳 / 跳首页)。 - 顶层导航不依赖跨站 cookie,补丁站(跨主域)同样可靠。 --- ## 08 — 创作者申请(Creator-Role Application) · /contracts/oauth/08-creator-applications # 08 — 创作者申请(Creator-Role Application) 返回 [README](./README.md) > 本节是**跨服务契约**:下游(kungal 论坛 / moyu 补丁站)按此调用 OAuth 的创作者申请队列。 > 角色由 OAuth 独占授予(身份契约:只有 OAuth 能发角色),**资格门槛由下游自治**。 > 设计背景与取舍见 infra 内部设计文档《创作者角色设计》(`docs/auth/01-creator-role-design.md`, > 仅 infra 仓 / 文档门户可见,本契约文件对下游自包含)。 ## 模型 `creator`(创作者)是 OAuth 的一个**附加角色**——可信发布者档位,赋予「galgame 直接发布 (含无 VNDB ID)」等发布信任能力。授予走 **申请 → 管理员审核 → 通过 / 拒绝(可重申)**: ``` 下游(论坛/补丁站): 按【自己的】门槛判定用户是否可申请(查 wiki /user/:id/stats + 自有数据) → 合格则带【用户 JWT】调 OAuth POST /creator/applications,附 evidence(满足了哪条) OAuth: creator_applications 中央队列 → 管理员审核 → approve 授予 creator 角色 / decline 附理由 用户: GET /creator/applications/me 查自己的申请状态 ``` - **资格判定在下游**(软门槛,会变、各站不同);**授予在 OAuth + 管理员**(硬门槛)。下游门槛写松最坏只是塞满审核队列,绝不能绕过人工审核拿到角色。 - `evidence` 是下游随申请带的「满足了哪条」提示,供管理员参考,**不作授权依据**。 ## 用户端点(Bearer / 用户 JWT) 挂 `middleware.Auth`,需登录用户的 access token。 ### POST /api/v1/creator/applications — 提交申请 ```jsonc // 请求体 { "source": "forum", // 必填,oneof: "forum" | "moyu"(申请来源站点) "message": "我的简介…", // 可选,max 1000,申请人附言(给管理员看) "evidence": { // 可选,jsonb,下游提供的「满足了哪条门槛」提示 "pr_merged": 7, "galgame_created": 12 } } ``` **成功**:返回创建的申请对象(见下「申请对象」),`status` = `pending`。 **失败**(HTTP 400 + 业务码,`message` 为可直接展示给用户的中文): | code | message | 含义 | |------|---------|------| | 17001 | 你已经是创作者了 | 用户已持有 `creator` 角色,无需申请 | | 17002 | 已有一份待审核的创作者申请 | 已有 pending 申请(同时只允许一个) | | 17003 | 申请被拒绝后需等待冷却期才能重新申请 | 上一次被拒后未过冷却期(**1 天**) | > 申请前 OAuth 不校验门槛(门槛在下游);这三个守卫只防重复 / 滥用申请。 ### GET /api/v1/creator/applications/me — 查我的申请 返回该用户**最近一条**申请对象。 > ⚠️ **从未申请过的用户**:服务返回 nil → 响应**省略 `data` 字段**(不是 `data: null`, > 是整个 key 不存在;envelope 的 `data` 带 `omitempty`)。下游必须把「`data` 缺省」当作 > **「从未申请」**处理,**不要**当错误。曾导致论坛首次访问用户 500(forum `3f4a61b5` 修复前)。 > > ```jsonc > // 从未申请: > { "code": 0, "message": "成功" } // 注意:没有 data > // 申请过: > { "code": 0, "message": "成功", "data": { /* 申请对象 */ } } > ``` ## 管理员端点(Admin JWT,不在下游接入范围) 挂 admin 组(`role=admin`)。列在此仅为契约完整: | 端点 | 作用 | 响应 | |------|------|------| | `GET /api/v1/admin/creator/applications?status=&page=&limit=` | 审核队列(`status` 缺省 `pending`) | `{ items: [申请对象 + user], total }`(每条多带申请人简介 `user`,见下「管理员列表额外字段」) | | `POST /api/v1/admin/creator/applications/:id/approve` | 通过 → 授予 `creator` 角色 + 标记 approved | `{ id, status: "approved" }` | | `POST /api/v1/admin/creator/applications/:id/decline` | 拒绝(body `{ reason?: max 500 }`) | `{ id, status: "declined" }` | 审核为状态机原子转移;对已处理的申请重复审核 → `17005`「该申请已被处理」;id 不存在 → `17004`「申请不存在」。 ## 申请对象 ```jsonc { "id": 12, "user_id": 1007, "source": "forum", // "forum" | "moyu" | … "status": "pending", // 状态枚举,见下 "evidence": { "pr_merged": 7 }, // omitempty:无则缺省 "message": "我的简介…", // 申请人附言(无则 "") "reviewer_id": 2, // omitempty:审核后才有 "reviewed_at": "2026-06-18T...", // omitempty:审核后才有 "decline_reason": "", // 拒绝时填,可直接展示给申请人 "created_at": "2026-06-18T...", "updated_at": "2026-06-18T..." } ``` **状态枚举 `status`**:`pending`(待审核)| `approved`(已通过)| `declined`(已拒绝)。 **管理员列表额外字段(仅 `GET /admin/creator/applications`)**:每个 item 在上述申请对象字段之外再带一个 `user` —— 申请人的 `UserBrief`(`id` / `name` / `avatar` / `avatar_image_hash` / `status` / `roles` / …),由 OAuth **服务端注入**,管理端 UI 直接展示,**无需**再调 S2S 专用的 `GET /users/batch`(那是 client Basic 鉴权的服务间端点,浏览器调会 401)。用户行已不存在时 `user` 为 `null`。`GET /creator/applications/me`(用户查自己)**不带** `user`。 ## 下游耦合点(重命名 / 重构时务必同步本节) 下游已硬编码以下事实,改动需在同一 PR 更新本契约并跑 `docs:sync`: 1. **角色字符串恰为 `creator`**——下游徽章 / 直发判定按 `slices.Contains(roles, "creator")`,重命名会静默失效。 2. **状态枚举值** `pending` / `approved` / `declined`(字符串,非数字)。 3. **`GET /creator/applications/me` 在「从未申请」时省略 `data` 字段**(非 `data: null`)——下游须按「缺省 = 从未申请」处理。 4. **申请 `source` 为 `oneof=forum moyu`**(新增下游站点需在此扩展)。 5. **业务错误码 17001 / 17002 / 17003 的 `message` 是可直接展示的中文**——下游 verbatim 透传给用户。 ## 已知限制(可接受) - **生效有延迟**:approve / decline **不向下游推事件**。下游在下一次 `/users/batch` 缓存刷新(约 10 分钟 TTL)或用户轮询 `/creator/applications/me` 时才反映新授予的角色。当前可接受;若将来需即时反馈,可加 webhook / role 版本号让下游在 approve 时失效缓存。 - 角色生效本身经 token 刷新后才进 JWT 的 `roles` claim,符合既有 OAuth 模型。 ## 变更摘要 > **2026-06-18 新增**:创作者申请契约纳入 OAuth 文档族并同步下游镜像。端点 `POST /creator/applications` + `GET /creator/applications/me`(用户)、`/admin/creator/applications*`(管理员);错误码 17001-17005。 --- ## 09 — 账号切换(多账号 / Account Switching) · /contracts/oauth/09-account-switching # 09 — 账号切换(多账号 / Account Switching) > ✅ **状态:后端 + OP 账号选择器已实现,契约稳定,下游可接入**。已上线:会话袋数据层、 > `/oauth/authorize` 的 `prompt=select_account|none` + `login_hint`、`/auth/sessions` > (+ `/switch`、`/logout`、`/logout-all`)、管理员 step-up;OP 账号中心(apps/web)与 > wiki 的切换器已接入。**待接入**:forum / moyu 的切换器 UI(照 §3.6 实现即可)+ > `prompt=none` 焦点对齐(§3.2,后续阶段)。本文是下游(kungal / moyu / wiki)接入 > 「多账号 + 切换」的**跨服务契约**;IdP 内部实现(数据模型、决策依据)见 infra 仓 > `docs/auth/02-account-switching-design.md`。 > ⚠️ 生产首次部署需对 `kun_galgame_infra` 跑 `go run ./cmd/migrate`(加 `sessions.browser_id` > 等列;部署不自动跑迁移)。 让用户像 Gmail/微软那样**同时登录多个账号并一键切换**。本文档读者 = 下游前端/后端开发; 讲「你要怎么调、能依赖什么保证」,不讲 IdP 内部表结构。 --- ## 1. 模型(先理解这三句) 1. **IdP 持有「会话袋」**:一个浏览器里登录的 N 个账号,全部由 OP(`oauth.kungal.com`)服务端持有,靠一个 httpOnly 的浏览器锚点 cookie 串起来。下游**不持有**多账号的 refresh token。 2. **每个下游 app 同一时刻只持有「当前账号」的令牌**。 3. **「切换」= 下游重新走一次到 OP 的授权码重定向**(`prompt=select_account`)。因为 OP 的浏览器 cookie 已经认识袋子里所有账号,所以切换**不需要重新输入密码**(管理员账号除外,见 §6)。 > 这是 `accounts.google.com` 的模型:会话归 OP,各产品跳过去选账号。因为我们的站点 > **跨顶级域**(kungal.com ↔ moyu.moe),切换**必须走重定向**,绝不能跨域共享 cookie。 ## 2. 关键基元(OIDC 标准参数,OP 已/将支持) | 参数 | 作用 | 备注 | |------|------|------| | `prompt=select_account` | 让 OP 渲染**账号选择器**(袋子里 ≥1 个账号时) | 新增 | | `prompt=none` | **静默**查询「当前是谁 / 我的会话还在吗」,OP 不渲染任何 UI;不能静默完成时回错误 | 新增 | | `prompt=login` | 强制重新认证(管理员 step-up / 登出后防静默登回) | **已存在**,见 [07-logout.md](./07-logout.md) | | 错误 `account_selection_required` | `prompt=none` 时用户登录了多个账号但没选 → 回退到选择器 | 新增 | | 错误 `login_required` / `consent_required` / `interaction_required` | `prompt=none` 无法静默完成 | 部分已存在 | `state` + PKCE 在每次重定向都**必须**带(见 §7)。 ## 3. 下游接入 ### 3.1 触发账号选择器(切换 / 添加账号) 顶层跳转到 OP 授权端点,带上 `prompt=select_account`: ``` GET https://oauth.kungal.com/api/v1/oauth/authorize ?client_id= &redirect_uri= &response_type=code &code_challenge=&code_challenge_method=S256 &state= &prompt=select_account ``` - OP 读浏览器锚点 cookie → 列出袋子里的账号(头像/昵称/邮箱)+「使用其他账号登录」。 - 用户选账号 B → OP 把 B 设为活跃 → **若 B 是管理员,先强制重登(§6)** → 下发授权码。 - 你的回调用授权码换**B 的新令牌**,替换当前令牌。除 step-up 外**无需输入凭据**。 > 想直接跳到某个已知账号(跳过选择器),可带 `login_hint=`;OP 在无歧义时可不渲染 UI。 ### 3.2 「全局活跃账号」对齐(本系统决策:global per browser) 活跃账号的**唯一真源是 OP**。下游靠**向 OP 静默询问**来对齐: - 在**页面加载 / 标签页获得焦点 / 路由切换**时,下游做一次 `prompt=none` 静默检查。 - 若 OP 的活跃账号 ≠ 本 app 当前账号 → 静默重新授权为活跃账号 → 替换令牌。 - **诚实的限制(跨顶级域固有)**:后台标签页**不会瞬时**切换,它会在**重新获得焦点/刷新**时对齐。这是跨 TLD 能做到的最好「全局」。 - **同 TLD 例外**:`oauth.kungal.com` 与 `www.kungal.com` 同属 `kungal.com`,可让锚点 cookie `Domain=.kungal.com` → kungal.com 家族内**瞬时全局**;只有 `moyu.moe` 走「焦点对齐」。 ### 3.3 会话袋 API(**仅同站 app**,如账号中心 `oauth.kungal.com/profile`) > 这些 JSON 端点读 OP 域上的 Lax 锚点 cookie,因此**只对与 OP 同站(`kungal.com` 家族: > `oauth.kungal.com` 自身、`wiki.kungal.com`、`www.kungal.com`)的前端可用**——同站下游若要 > 跨子域调用,还需 OP 为该 origin 放行 **CORS(`credentials`)**。跨 TLD 的 `moyu.moe` > **用不了**(`SameSite` cookie 不跨站 `fetch` 发送)→ 一律走 §3.1 重定向 + §3.6 本地缓存。 > > ⚠️ **坑(同站接入必看)**:`switch` / `logout` 会返回**业务性 401**(`10016` step-up、 > `10005` 不在袋中、`10001` 非成员)。别让前端「遇 401 就刷新令牌 / 跳登录」的**全局拦截器** > 吞掉它们——这几个调用要**绕过全局 401 处理**、自己读响应 `code` 分支(否则 step-up 会被 > 误判成会话失效而把用户登出)。参考实现:apps/web `useAccountSwitch.ts` 用裸 `$fetch` > 而非全局封装。 | 方法 | 路径 | 作用 | 响应(`data`) | |------|------|------|------| | GET | `/api/v1/auth/sessions` | 列出本浏览器袋子里的账号 | `{ items: [{ sub, name, email, avatar, avatar_image_hash?, roles, active, last_used_at }] }`(`roles` 供选择器显示角色徽标 + 管理员「需重新登录」提示) | | POST | `/api/v1/auth/sessions/switch` | 把某账号设为活跃(同站内即时全局) | `{ user, access_token }`(即登录响应体;并轮换 `refresh_token` cookie。前端可直接更新缓存 + 用新 access_token) | | POST | `/api/v1/auth/sessions/logout` | **登出此账号**(撤销该会话) | `null`(成功;目标不在袋中 → 404 / 10005) | | POST | `/api/v1/auth/sessions/logout-all` | **登出全部**(撤销袋内所有会话 + 清锚点 cookie) | `null` | 请求体:`switch` / `logout` 带 `{ sub }`(账号的 user uuid)。鉴权 = 用户 JWT(**Bearer**,`Authorization` header)——非 cookie 鉴权,天然免 CSRF,无需额外 CSRF 令牌。**调用者(Bearer 身份)必须是该浏览器袋子的成员**,否则 401(防 confused-deputy:仅凭锚点 cookie 不足以操作袋子)。 ### 3.4 登出语义(本系统决策:基于撤销 / revocation-based) > **关键:单次「退出登录」只应登出当前账号,不要清空整个袋子。** OIDC 规范本身倾向「按 > 会话登出」(用 `sid` 指明登出**哪一个**会话),**没有**规定「登出 = 登出所有账号」;Google > 网页版的「登出即登出全部」是其产品取舍(且广受诟病,移动端就是逐个登出)。多账号最佳实践 > = 同时提供**「退出当前账号」+「退出全部账号」**两个动作。 - **退出当前账号**(`POST /auth/sessions/logout {sub}`):OP 硬删除该账号的会话(删了就刷不动 = 撤销),从袋子移除;**其他账号不受影响**。若删的是当前活跃账号,OP 顺带清 `refresh_token` cookie。推荐下游 UX:删除后**落到袋内剩余的某个账号**(仍保持登录其它账号,类 Gmail 移动端),袋空了才回登录页——**不要**一登出就把人踢回登录页/清掉所有账号。参考实现(apps/web 账号中心切换器):先 `switch` 到剩余的某个非管理员账号(这样调用者仍是袋成员 → 过 §3.3 的 confused-deputy 校验)再 `logout` 旧账号。 - **退出全部账号**(`POST /auth/sessions/logout-all`):撤销袋内全部会话 + 清锚点 cookie。**仅在用户显式选择**「退出全部」时调用。 - **传播机制 = 撤销 + 短 access token TTL**(不用 back-channel / iframe):access token 寿命 **~10–15 分钟**;任意 app 刷新时若会话已撤销则刷新失败 → 一个 TTL 内登出。想更快就调短 TTL,这是唯一旋钮。 - 这样选是因为:下游是 SPA,**没有服务端 RP 会话**可供 back-channel logout 关联 `sid`;front-channel iframe 又依赖跨站 cookie(脆弱)。撤销式最简单、坑最少、可日后再叠加即时传播而无需重构。 - **与 RP 发起登出([07-logout.md](./07-logout.md))的区别(别混用)**:上面的 `logout` / `logout-all` 是**同站、OP 侧的会话袋管理**(账号中心用)。**跨 TLD 下游**要登出自己这个 app 的会话,走 RP 发起登出(顶层跳 OP 登出入口,见 07);要切到别的账号走 §3.1 重定向 / §3.6 切换器。 ### 3.5 管理员 step-up(本系统决策:切入管理员账号需重新认证) - 当选中的账号持 `admin` / `ren` 角色,切换会被拒(`/auth/sessions/switch` 返回 `10016`; 重定向流则由 OP 强制 `prompt=login`)→ 必须重新认证后才激活并下发令牌。 - OP 记录 `auth_time`(已落库,为 `max_age` / 管理端点的「最近认证时间」校验**预留**;当前 仅切入 admin/ren 强制重登已生效,过旧 auth_time 的细粒度校验留待后续)。 - 下游无需特殊处理——照常走 §3.1 重定向即可,OP 自动插入重登;走 JSON `switch` 的同站前端 收到 `10016` 时跳 `/oauth/authorize?prompt=login&login_hint=` 重认证(见 §3.6)。 ### 3.6 账号选择器 UX(推荐下游**统一**实现) 理想交互:点头像 → 弹出菜单 → 「切换账号」项(桌面 hover / 手机点击)→ 二级菜单列出可切换账号 + 「添加新账号」。本设计**支持**它,机制如下,各站 UI 应统一这么做: **二级菜单的账号列表 = 本 app 的「已知账号」本地缓存(localStorage)**,不依赖跨站读 OP 袋子: - 缓存项 = `{ sub, name, avatar, avatar_image_hash?, email, roles? }`(够渲染头像 / 邮箱 / 角色徽标),**不含任何令牌**。每次本 app 成功登录/切换到某账号后,从 `GET /auth/me` 取身份写入缓存。 - 二级菜单直接渲染这个本地列表——**跨 TLD 也能显示**(因为不需要 OP 的 cookie)。 - 点某账号 → **顶层跳转** `…/oauth/authorize?prompt=select_account&login_hint=&state=…&code_challenge=…`。顶层导航会带上 OP 的 Lax 锚点 cookie,OP 凭袋子静默切到该账号 → 回调换令牌。若 OP 袋子里已无该账号(在别处登出了)→ 优雅回退到登录。 - 「添加新账号」→ 顶层跳转 `…/oauth/authorize?prompt=login&…`(或选择器里的「使用其他账号」)。 **同站 app(kungal.com 家族)可选增强**:直接 `GET /auth/sessions`(同站 cookie 会发送)实时拉取**准确**的袋子列表来覆盖本地缓存——列表永远最新。 **诚实的限制(跨 TLD,如 moyu)**:本地缓存可能滞后真实袋子——在 forum 新加的账号 C,moyu 的二级菜单要等你在 moyu 上经 OP 切过一次才出现;在别处登出的账号点切换会回退到登录。OP 袋子始终是真源,切换永远以它为准,「添加新账号」永远可用 → 体验**可用且一致**,只是跨 TLD 的列表是「尽力而准」。 > 用各站自己的菜单/下拉组件实现嵌套(桌面 hover、移动端点击展开)。纯前端,不影响以上数据契约。**参考实现(可直接照搬到 forum/moyu)**:apps/wiki 的 `components/auth/AccountMenu.vue`(嵌套菜单 + 角色徽标 + 「切换需重新登录」提示)+ `composables/useKnownAccounts.ts`(localStorage 缓存,SSR 安全)+ `composables/useOAuthLogin.ts`(带 `prompt`/`login_hint` 的授权重定向)。**后端要求**:`/oauth/authorize` 接受 `login_hint`、`GET /auth/me` 返回身份(均已有)。 ## 4. 安全要求(下游 **必须**,依据 RFC 9700 / RFC 6819) - [ ] 每次切换/添加/对齐重定向都带**一次性、绑定 user-agent 的 `state`** + **PKCE**(SPA 是 public client)。防止伪造回调把受害者**静默切到攻击者账号**。 - [ ] 回调/返回 URL 必须在 OP 白名单内(复用 [07-logout.md](./07-logout.md) 的 `post-logout-redirect` 白名单机制);**不得**跳转到任意 query 参数 URL(开放重定向会泄露授权码/令牌)。 - [ ] **当前账号永远从令牌(`sub`)推导,绝不信任前端传的「账号 id」**;每个写操作归属于令牌的 `sub`。 - [ ] access token 应 `aud` 限定到本 app 的资源服务器(moyu 的令牌不能拿去打 kungal 的 API)。 - [ ] UI 上**醒目展示当前账号**(头像+昵称在 header),防止后台标签页 stale 导致「以为是 A 实际是 B」。 ## 5. 错误码(实现已落地;详见 [04-tokens-and-errors.md](./04-tokens-and-errors.md) 认证段) > 后端错误统一走 `{ code, message }`(HTTP status + 业务 code 同时返回,下游**两者都要看**)。 > 切换/登出的业务错误复用认证段(`10xxx`),不另开 `18xxx` 段。 | 场景 | HTTP | code | |------|------|------| | 需要选择账号(`prompt=none` 多账号未选,前端 OP 页判定) | 重定向回下游 `error=account_selection_required` | — | | 切换/登出目标不在袋中(不存在/已撤销) | 404 | `10005`(ErrAuthUserNotFound) | | 切换目标需 step-up(管理员/ren,未重认证) | 401 | `10016`(ErrAuthStepUpRequired)→ 前端跳 `prompt=login` | | 调用者非该袋成员(confused-deputy 防护) | 401 | `10001`(ErrAuthUnauthorized) | ## 6. 下游耦合点(重命名/重构时务必同步本节) 下游会硬编码以下事实: 1. OP 授权端点接受 `prompt=select_account` / `prompt=none` / `prompt=login`。 2. 「全局活跃账号」语义 = 焦点对齐(非后台标签页即时),下游 UI 须照此设计预期。 3. 登出传播 = 撤销 + 短 TTL(**不是**即时跨 app 登出)。 4. 同站才有会话袋 JSON API;跨 TLD 只有重定向流。 ## 7. 实施阶段(与 `docs/auth/02` 对齐) 1. ✅ OP:会话袋 + `prompt` 处理 + 选择器页 + `/auth/sessions` API + 管理员 step-up(DB 迁移 `kun_galgame_infra`:`sessions` 加 `browser_id` 等)。单账号行为不变。 2. ✅ 账号中心(apps/web,同 OP 家族)站内切换器(in-place switch + 角色徽标)。 3. 🚧 逐站接入 forum / moyu / wiki 切换器 UI(**wiki ✅ 已接入**;forum / moyu 待做)+ `prompt=none` 焦点对齐(待做)。 4. ⏳ 调短 access TTL + 开审计日志。 --- ## 变更摘要 > **2026-06-25(登出语义)**:澄清 §3.4——单次「退出登录」只登出**当前账号**(非清空整个袋子;这非 RFC 强制、Google 网页版「登出全部」是其取舍),多账号应提供「退出当前账号」+「退出全部账号」两个动作;补充与 RP 发起登出(07)的区别。apps/web 账号中心切换器据此实现(退出当前 = 先切到剩余账号再 `logout`,退出全部 = `logout-all`)。 > **2026-06-24(实现)**:后端 + OP 账号选择器落地——会话袋(`sessions.browser_id/auth_time/last_used_at`)、`prompt=select_account|none` + `login_hint`、`/auth/sessions`(+ switch/logout/logout-all,Bearer + confused-deputy 防护)、管理员 step-up(`10016`);apps/web 站内切换器 + wiki 切换器(本地缓存 + 重定向);`SessionBrief` 带 `roles`(角色徽标 + 「切换需重新登录」提示)。错误码复用 `10xxx`(非 `18xxx`)。forum / moyu 切换器待接入。 > **2026-06-24(设计)**:新增本文。多账号切换契约:会话袋在 OP;切换走 `prompt=select_account` 重定向;全局活跃 = 焦点对齐(同 `.kungal.com` 可瞬时、moyu 跨 TLD 焦点对齐);登出 = 撤销 + 短 TTL;管理员切入需 `prompt=login` 重登。安全沿用 07 的白名单 + 全程 `state`+PKCE。 --- ## 10 — 应用目录(生态一键登录 / App Directory) · /contracts/oauth/10-app-directory # 10 — 应用目录(生态一键登录 / App Directory) > 🚧 **状态:后端只读端点 + 数据模型已实现;前端展示分阶段接入。** 让用户在注册/登录时 > 看到「拥有一个鲲 Galgame 账号,即可一键登录以下网站」——按 OAuth client 的开关(opt-in) > 列出生态内的站点。本文是下游(kungal / moyu / wiki)展示这条「生态 strip」的跨服务契约。 这对应业界成熟的 **App Launcher / app directory** 模式(Salesforce、Cloudflare Access、 Microsoft 365 的「九宫格」),各家都是**私有实现 + 每个 app 一个可见性开关**——OAuth/OIDC **没有**公开列举 client 的标准,所以这是产品元数据,不是协议缺口。本文的形态是「注册/登录 页的生态价值展示」,数据与 App Launcher 同源。 --- ## 1. 模型:每个 OAuth client 一个 opt-in 开关 `oauth_clients` 新增(管理员在 OAuth 后台「OAuth 客户端」里配置): | 字段 | 含义 | |------|------| | `listed` (bool, 默认 `false`) | 是否出现在公开应用目录里。**opt-in**——内部/管理类 client 默认不出现 | | `logo_url` (string) | 站点 logo(strip / tile 用) | | `tagline` (string) | 一句话简介,如「Galgame 论坛」 | | `display_order` (int, 默认 0) | 排序(小在前,再按 name) | > 列表内容 = 所有 `listed=true` 的 client。复用已有的 `name` 与其所属 Site 的 `domain`。 ## 2. 端点:`GET /api/v1/oauth/ecosystem`(公开,可缓存) 无需鉴权——这是**公开营销元数据**,只暴露展示字段,**不含** secret / redirect_uris / scope / 配额。建议前端缓存(内容很少变)。 响应(`data`): ```json { "apps": [ { "name": "鲲Galgame论坛", "site_domain": "www.kungal.com", "logo_url": "https://…", "tagline": "Galgame 论坛", "auto_consent": true }, { "name": "摸鱼galgame", "site_domain": "moyu.moe", "logo_url": "https://…", "tagline": "Galgame 补丁站", "auto_consent": true } ] } ``` 只返回 `listed=true` 的 client;排序 = **官方(`auto_consent=true`)在前**,再 `display_order`,再 `name`。`auto_consent` 复用已有的第一方标志(见 doc 05):`true` = 第一方「官方」站点,下游据此展示「官方」标识并排在前面。 ## 3. 下游接入(展示「生态 strip」) 同一个端点,三处复用: 1. **OAuth 注册页**(`oauth.kungal.com` 自身,apps/web):注册卡片下方一条 logo strip—— 「拥有鲲 Galgame 账号,一键登录以下网站」。同源,直接调用。 2. **OAuth 授权页**(某个 client 的登录流程中):高亮**当前正在登录的 client**,其余列为 「也可用此账号登录:…」,把同意/登录这一刻变成价值展示。 3. **下游登录/注册 modal**(kungal / moyu / wiki):`fetch https://oauth.kungal.com/api/v1/oauth/ecosystem` 渲染同样的 strip。 **CORS(下游跨域读取必看)**:该端点是公开 GET,但下游浏览器跨域 `fetch` 时,**消费方的 origin 必须在 OP 的 CORS 白名单内**(`internal/middleware/cors.go`:目前含 `kungal.com`、 `moyu.moe` + 开发 origin)。接入子域(`www.kungal.com` / `wiki.kungal.com`)时需把其 origin 加进白名单,否则被 CORS 拦截。 **UX 建议**:logo + 名称、官方站点(`auto_consent`)显示 primary「官方」chip 并排在前、缓存 端点;纯展示,不触碰认证流。参考实现:apps/web 注册页(折叠为一排圆形 icon + 点击展开列表)、 moyu 登录 modal。 ## 4. 安全 / 隐私 - 端点**只**返回 opt-in client 的公开展示字段——无 secret / 内部布局,可安全公开 + 缓存。 - `listed` 默认 `false`(fail-closed):内部 / 管理 client 不会意外出现在公开目录。 - **不在认证路径上**:纯只读元数据,不改 token / session / 重定向,安全面几乎为零。 ## 5. 实施阶段 1. ✅ 后端:`oauth_clients` 加 `listed`/`logo_url`/`tagline`/`display_order` + `GET /oauth/ecosystem`(公开、按序)。(DB 迁移 `kun_galgame_infra`:`cmd/migrate`) 2. 🚧 OAuth 后台:客户端创建/编辑表单加这四个字段(管理员配置)。 3. 🚧 OAuth 注册页 strip + 授权页「当前 client 高亮」。 4. 🚧 下游 kungal / moyu / wiki 登录/注册 modal 接入 strip(+ 按需扩 CORS 白名单)。 5. ⏳(可选)登录后的 **App Launcher**(账号中心九宫格)——同源数据,后续可叠加。 --- ## 变更摘要 > **2026-06-25(设计 + 后端)**:新增本文。生态「一键登录」应用目录:`oauth_clients` 加 > opt-in `listed` + `logo_url`/`tagline`/`display_order`;公开只读 `GET /oauth/ecosystem` > 返回 `listed` client 的展示字段。前端 strip(OAuth 注册/授权页 + 下游 modal)分阶段接入。 > 对应业界 App Launcher 模式(无 OAuth 标准,属产品元数据)。 --- ## 角色与能力语义(权威定义) · /contracts/oauth/11-roles # 角色与能力语义(权威定义) 返回 [README](./README.md) --- > **本文是全站五角色及其能力语义的唯一权威来源(Tier A)。** 角色由 OAuth IdP 统一定义、统一通过 JWT 下发;**下游 kungal(论坛)、moyu(补丁站)、wiki 等所有 RP 必须遵守本文的语义,不得自行发明与此冲突的解释。** 任何站点的鉴权(后端为准)在把 `roles` claim 映射成内部权限时,都必须满足本文 §3 的能力序与 §4 的强制规则。 --- ## 1. 五个角色 | 角色名(claim 字符串) | 中文 | 语义 | 可经 API 授予? | |---|---|---|---| | `user` | 普通用户 | **隐式默认身份**——任何登录用户。**不是被显式授予的角色**(见 §2) | —(隐式) | | `creator` | 创作者 | **可信发布者**:可直接发布 galgame(跳过审核队列 + 跳过每日提交配额)。**与审核/管理正交,不含任何审核或管理权** | 是(admin / ren 可授;也可走申请流程,见 [08](./08-creator-applications.md)) | | `moderator` | 版主 | **内容审核**:可处置**他人**的内容(编辑/删除/置顶/隐藏/审核提交) | 是(admin / ren 可授) | | `admin` | 管理员 | **站点与用户管理**:用户管理、站点/OAuth 客户端、统计、封禁、角色授予等;含 moderator 全部能力 | 是(仅 ren 可授) | | `ren` | 莲 | **admin 之上的操作者**:存储配置、artifact 文件运维、PII 可见、可授予任意角色;含 admin 全部能力 | **否**(仅可直接配置 DB,永不经 API 授予) | > 角色集是**固定的 5 个**,种子定义在 IdP 代码 `cmd/migrate`。除此之外的任何字符串(含历史别名 `super_admin`)**不是有效角色**,下游不得依赖。 --- ## 2. 角色如何下发(claim 格式 —— 权威) - access_token 的 `roles` claim 是一个**角色名字符串数组**(见 [04 JWT Claims](./04-tokens-and-errors.md#jwt-access-token-claims))。 - **`roles` 是一个无序集合,不是有序列表,也不是数值等级。** 下游**不得**假设元素顺序,**不得**假设某个角色一定在/不在数组里(除非按集合成员判断)。 - **普通用户的 `roles` 为空数组 `[]`。** 字符串 `"user"` **不会**出现在 claim 里——它是隐式默认。**下游不得用"数组里有没有 `user`"来判断是否登录**(用是否持有有效 token 判断登录;用"数组是否含某个提权角色"判断提权)。 - 角色是**可叠加的**:一个账号可同时持有多个角色(例如所有 `ren` 账号都**同时持有 `admin`**,见 §3.2 不变量)。 - 角色变更在**下一次 token 刷新**后生效。下游**必须**每次从最新 claim / userinfo 重新解析角色,**不得**把角色长期缓存到失去时效(提权/降权要能在会话中途生效)。 --- ## 3. 能力语义与层级(权威) ### 3.1 两条独立的轴 能力分两条**互相正交**的轴,下游必须分别理解: 1. **管理/审核轴(有序,逐级包含):** ``` 普通用户 < moderator < admin < ren ``` 高一级**必须**被视为拥有低一级在本站的全部能力。即: - 任何持有 `admin` 的账号,**必须**被授予 `moderator` 的全部能力; - 任何持有 `ren` 的账号,**必须**被授予 `admin`(以及因此 `moderator`)的全部能力。 2. **发布轴(正交):** `creator` 是"可信发布者"标记,**只**赋予 galgame 直接发布能力(跳过审核 + 跳过提交配额),**不**赋予任何审核或管理能力。它独立于管理轴——一个 `creator` 不是 moderator,一个 moderator 不自动是 creator。 > 注意:管理轴的"逐级包含"是**契约层的强制语义**(§4 规则 2),下游必须在自己的鉴权里实现它,**不能**因为内部用数值等级或角色名白名单就把某个高角色漏掉。 ### 3.2 运行不变量:`ren` 必与 `admin` 同授 IdP 保证(并由运维约定):**任何 `ren` 账号都同时持有 `admin`**。但下游**不得**把这条当作可省略 ren 处理的借口——按 §4 规则 2,下游仍必须把 `ren` 当作 ≥ `admin` 来对待,使系统在该不变量被打破时依然正确。 ### 3.3 能力语义速查 | 能力(语义层,不是穷举接口) | 最低角色 | 说明 | |---|---|---| | 浏览公开内容 | 匿名 | 无需登录 | | 发帖/发补丁/评论/提交 galgame 草稿/创建分类/提 PR/编辑删除**自己的**内容 | `user`(登录) | 任意登录用户 | | **直接发布 galgame**(跳过审核队列 + 跳过每日提交配额,可省略 vndb_id) | `creator` | 由 wiki 后端在提交流中实现;经下游"提交"入口代理到 wiki 时同样生效 | | 编辑/删除/置顶/隐藏/审核**他人**的内容、galgame 提交审核(通过/拒绝/封禁) | `moderator` | 内容审核 | | 用户管理(封禁/匿名化/强制下线/调萌萌点)、站点 & OAuth 客户端管理、授予 moderator/creator 角色、各站点级配置 | `admin` | 站点管理 | | 客户端存储能力配置、artifact 文件浏览/删除/清理、查看用户 PII(邮箱/IP)、授予任意角色 | `ren` | IdP 专属;详见 IdP 后台,非下游接口 | > galgame/wiki 相关能力的**具体接口与门禁**以 [galgame_wiki 契约](../galgame_wiki/) 为准(该契约也属 Tier A);本文只定义角色与能力的**语义映射**。各下游站点自身内容(帖子/补丁/评分等)的具体接口在各自仓库,但其角色判定**必须**符合本文 §3、§4。 --- ## 4. 下游必须遵守的规则(MUST) 1. **以集合语义解析 `roles`。** 按"是否包含某角色名"判断,不依赖顺序;不靠 `"user"` 是否在数组里判断登录(§2)。 2. **实现管理轴的逐级包含。** 任何把 `roles` 映射到内部权限的逻辑,**必须**让 `ren ⊇ admin ⊇ moderator`: - 不得把 `ren` 当作普通用户或忽略; - 不得把 `admin` 排除在 moderator 能做的事之外。 - 若内部用数值等级,**必须**:`ren`、`admin` → 最高管理级;`moderator` → 审核级;映射表必须覆盖所有提权角色,不得静默丢弃。 3. **`creator` 仅作发布能力,不授予审核/管理权。** 不得用 `creator` 放行任何审核或后台操作。 4. **后端为唯一鉴权点;前端门禁仅 UX。** 前端可隐藏 UI,但**不得**作为唯一防线;真正的权限判断必须在后端按 claim 做。前端"更严于后端"(例如把某操作藏在 admin 之后而后端只要 moderator)允许,但属 UX 不一致,应尽量对齐。 5. **不得在下游实现角色授予/撤销。** 角色变更只在 OAuth 后台进行(矩阵见 §5);下游只读 claim。`creator` 申请走 [08](./08-creator-applications.md) 的中央队列,授予仍归 OAuth。 6. **角色时效。** 每次刷新重新解析角色,使提权/降权能在会话中途生效(§2)。 --- ## 5. 角色授予矩阵(仅 OAuth 后台) | 操作者 | 可授予 / 撤销 | |---|---| | `ren` | `user` / `creator` / `moderator` / `admin`(任意可管理角色) | | `admin` | **仅** `moderator` / `creator` | | 其他 | 无 | - **`ren` 永不可经 API 授予/撤销**(只能直接配置 DB)。 - **不能修改自己的角色。** - 成为 `creator` 另有自助申请通道(任意登录用户申请 → admin 审核 → 授予),见 [08-creator-applications.md](./08-creator-applications.md)。 --- ## 6. 当前下游合规差距(必须整改) > 截至本文落地时,下游对 `ren` 的处理与本契约不符,**必须修复**: - **kungal(论坛)**:后端把 `roles` 折叠成数值等级,只认 `admin`/`super_admin`→最高、`moderator`→审核,**其余(含 `ren`、`creator`)全部塌成普通用户**。→ 必须让 `ren` 映射到最高管理级(等同 `admin`)。当前仅因 ren 账号同时持有 `admin` 才未出事(违反 §3.2 的健壮性要求)。 - **moyu(补丁站)**:后端按角色名判定,但**完全不识别 `ren`**——一个只持 `ren` 的账号会被所有门禁拒绝。→ 必须把 `ren` 视为 ≥ `admin`。 - 两站对 `creator` 的"不授予审核权"处理符合本契约(§3.1),无需改动。 - 历史别名 `super_admin`:IdP 从不签发,下游可移除对它的特殊处理(留着也无害,因为不会出现)。 整改后,只持 `ren`(无 `admin`)的账号也应在三站获得完整管理权,系统不再依赖"ren 必同时持 admin"这一运维约定。 --- ## 7. 关联文档 - [04-tokens-and-errors.md](./04-tokens-and-errors.md#jwt-access-token-claims) —— `roles` claim 的 JWT 位置。 - [08-creator-applications.md](./08-creator-applications.md) —— `creator` 申请/审批流程。 - [galgame_wiki 契约](../galgame_wiki/) —— galgame 编辑/审核/发布的具体接口与角色门禁。 --- ## 鲲 Galgame OAuth 文档 · /contracts/oauth # 鲲 Galgame OAuth 文档 基础路径:`/api/v1` | 环境 | Base URL | |------|----------| | 开发 | `http://127.0.0.1:9277/api/v1` | | 生产 | `https://oauth.kungal.com/api/v1` | ## 重要约定:身份操作必须在 OAuth 完成 下游 kungal / moyu / wiki **不要在自己前端实现下列操作**: - **新用户注册**(跳转到 `oauth.kungal.com/auth/register?redirect=`,注册成功后自动 SSO 回跳)—— 详见 [05-registration.md](./05-registration.md) - **改邮箱**(POST /auth/email/send-code + PUT /auth/email) - **改密码**(PUT /auth/password) - 重设密码 / 启用 2FA / 管理登录设备 / 注销账号 / 撤销已授权 OAuth Client(未来) 跳转目标:注册去 `/auth/register?redirect=...`,账号管理去 `https://oauth.kungal.com/profile`。 技术上这些端点都能通过 end-user JWT 代理,但身份层操作**必须集中在一个前端**:安全审计单点、未来加 2FA / 异地通知时只改一处、避免邮箱劫持攻击面跨多个站点放大。 展示层操作(name / avatar / bio)可以站内提供 UI 或跳转,任选。 详细分类表 + 跳转按钮代码示例见 [02-user-profile.md §身份操作 vs 展示操作](./02-user-profile.md#身份操作-vs-展示操作)。 --- ## 文档索引 ### API 参考(按主题) | # | 文件 | 内容 | |---|------|------| | 01 | [oauth-endpoints.md](./01-oauth-endpoints.md) | OAuth 2.0 协议端点:`/oauth/token`、`/oauth/authorize`、`/oauth/userinfo`、`/oauth/revoke` | | 02 | [user-profile.md](./02-user-profile.md) | 用户自助:`GET/PATCH /auth/me` + `POST /auth/me/avatar`(含头像上传) | | 03 | [cross-service.md](./03-cross-service.md) | 服务到服务:`/users/batch`、`/users/search`(OAuth Client Basic Auth) | | 04 | [tokens-and-errors.md](./04-tokens-and-errors.md) | JWT Access Token claims + 完整错误码速查(OAuth 15xxx / 认证 10xxx / 通用) | | 05 | [registration.md](./05-registration.md) | 用户注册流程:跳转 OAuth 注册 + 邮箱验证码 + 自动 SSO 回跳;`POST /auth/register/send-code` + `POST /auth/register`、`GET /oauth/client-info`;下游 PKCE 跳转示例 | | 06 | [moemoepoint.md](./06-moemoepoint.md) | **设计规范(精简版)**:萌萌点全站统一货币(单一真源在 OAuth)。可变余额列 + append-only 审计日志 + 幂等发放/扣除 RPC + 迁移与下游接入;含"刻意没做的"清单(将来需要再升级)| | 07 | [logout.md](./07-logout.md) | **登出与单点登出(RP-Initiated Logout)**:修复「登出后再登录直接静默登回原账号」。RP 登出须顶层跳转 OP 登出入口 `GET /auth/logout`;含 `GET /oauth/post-logout-redirect` 白名单校验 + `prompt=login` 强制重登;下游接入步骤 | | 08 | [creator-applications.md](./08-creator-applications.md) | **创作者申请(Creator-Role Application)**:申请 → 管理员审核 → 通过/拒绝(可重申)的中央队列。`POST /creator/applications` + `GET /creator/applications/me`(用户);资格门槛**下游自治**、角色授予归 OAuth;含「从未申请省略 `data`」契约 + 错误码 17001-17005 + 下游耦合点 | | 09 | [account-switching.md](./09-account-switching.md) | ✅ **账号切换(多账号 / Account Switching)——后端 + OP 选择器已实现,下游可接入**:Gmail 式多账号 + 一键切换。会话袋在 OP;切换走 `prompt=select_account` + `login_hint` 重定向(同站可用 `/auth/sessions` JSON API);全局活跃 = 焦点对齐(同 `.kungal.com` 瞬时 / moyu 跨 TLD 对齐);登出 = 撤销 + 短 TTL;管理员切入需重登(`10016`)。apps/web + wiki 切换器已接入,forum/moyu 待做。内部实现见 infra `docs/auth/02` | | 10 | [app-directory.md](./10-app-directory.md) | 🚧 **应用目录(生态一键登录 / App Directory)**:注册/登录时展示「拥有一个鲲 Galgame 账号即可一键登录以下网站」。每个 OAuth client 一个 opt-in `listed` 开关 + `logo_url`/`tagline`/`display_order`;公开只读 `GET /oauth/ecosystem` 返回 `listed` client 的展示字段;下游 modal / OAuth 注册页展示「生态 strip」。对应业界 App Launcher 模式(无 OAuth 标准,属产品元数据)| | 11 | [roles.md](./11-roles.md) | ⚖️ **角色与能力语义(权威定义,Tier A)**:全站五角色 `user`/`creator`/`moderator`/`admin`/`ren` 的唯一权威来源。`roles` claim = 角色名集合(普通用户为空数组,`user` 隐式);管理轴逐级包含 `moderator ⊂ admin ⊂ ren`,`creator` 为正交的「直接发布」能力;**下游必须遵守的 MUST 规则** + 授予矩阵 + 当前 kungal/moyu 对 `ren` 的合规差距(必须整改)| ### 完整接入指南 | 文件 | 内容 | |------|------| | [oauth-integration-guide.md](./oauth-integration-guide.md) | 端到端 OAuth 接入走查:注册 client、PKCE、token 轮换、并发刷新、跨域 / 跨站坑、安全注意事项 | | [nuxt-integration-prompt.md](./nuxt-integration-prompt.md) | Nuxt 3/4 项目的快速上手指南(含 SSR 回调处理代码) | --- ## 响应格式 ```json { "code": 0, // 0 = 成功,非零 = 错误码 "message": "成功", "data": { ... } // 成功时有数据;失败时一般为 null 或缺省 } ``` > **认证失败返回 HTTP 401 / 403**:`/auth/me` / `/auth/*` 等受保护端点(protected 组,挂 `middleware.Auth`,见 `cmd/oauth/main.go`)在 token 缺失 / 失效 / 过期时返回 **HTTP 401** + `{ code: 10001 | 10002 | 10003, message }`;权限不足返回 **HTTP 403**。下游客户端应同时检查 HTTP status 与 `code`。完整列表见 [04-tokens-and-errors.md §认证错误](./04-tokens-and-errors.md#认证错误-10xxx)。 ## 认证 OAuth 一共有三种鉴权方式,按场景区分: | 方式 | 用在哪 | 谁有 | |------|------|------| | **Bearer Token**(用户 JWT) | `/auth/*` 用户自助 + `/oauth/userinfo` | 已登录的终端用户 | | **OAuth Client Basic Auth** | `/users/batch`、`/users/search`(跨服务) | 已注册的 OAuth Client(kungal / moyu / wiki 等下游后端) | | **Admin JWT**(Bearer + role=admin) | `/admin/*`(不在本文档范围) | OAuth 后台管理员 | 终端用户 JWT 通过完整的 OAuth Authorization Code + PKCE 流程拿到(详见 [oauth-integration-guide.md](./oauth-integration-guide.md))。Client Basic Auth 的 client_id / client_secret 在 OAuth 后台创建 Client 时生成。 --- ## 变更摘要 > **2026-06-27 角色语义定权威(重要)**:新增 [11-roles.md](./11-roles.md)——把全站五角色 `user`/`creator`/`moderator`/`admin`/`ren` 及其能力语义定为 **Tier A 权威**,下游必须遵守。要点:① `roles` claim 是**角色名集合**,普通用户为**空数组**(`user` 隐式,下游不得用「数组含 `user`」判断登录);② 管理轴**逐级包含** `moderator ⊂ admin ⊂ ren`,任何把 claim 映射成内部权限的逻辑必须让 `ren ⊇ admin ⊇ moderator`;③ `creator` 是**正交**的「直接发布 galgame」能力,不含审核/管理权。**下游 kungal / moyu 必须整改对 `ren` 的处理**:kungal 数值等级把 `ren` 塌成普通用户、moyu 完全不识别 `ren`——目前仅因「ren 账号必同时持 admin」未出事,违反健壮性要求,须修复(见 11 §6)。 > **2026-06-14 登出修复(RP-Initiated Logout)**:新增 [07-logout.md](./07-logout.md)。修复「在 wiki / 补丁站登出后,再点登录/注册会静默登回刚才的账号」——根因是 RP 登出没清掉 OP(`oauth.kungal.com`)的会话(OP 的 `localStorage` 跨 origin 清不掉 + 跨站 cookie 带不过去)。方案:RP 登出时**顶层跳转**到 OP 登出入口 `GET https://oauth.kungal.com/auth/logout?client_id=&redirect=`,由 OP 清会话再回跳。新增后端 `GET /oauth/post-logout-redirect` 白名单校验 + `GET /oauth/authorize` 的 `prompt=login` 参数。**下游 kungal / moyu 必须改登出实现**(见 07)。 > **2026-05-23 注册流程统一(L1,重要)**:新增 [05-registration.md](./05-registration.md) 文档;引入**邮箱验证码两步注册**——`POST /auth/register/send-code` 寄码 + `POST /auth/register` 带 code 创建账号并发 token(**注册即登录**,返回 access_token + 写 refresh cookie);新增 [GET /oauth/client-info](./05-registration.md#get-oauthclient-info) 公开元数据端点;`oauth_clients` 加 `auto_consent` 列,5 个第一方 client 默认开启——同意页对第一方静默跳过,用户感知是"注册完一闪回到原站点已登录"。下游 kungal / moyu 的 legacy 注册端点全部删除,"注册"按钮改为复用登录的 PKCE 跳转模式(目标 URL 换成 `/auth/register?redirect=`)。 > **2026-05-23 政策**:明确"身份层 vs 展示层"分类。下游禁止在自己前端做改邮箱 / 改密码 / 注销账号等身份操作,必须跳转 OAuth profile。详见上方"重要约定"小节和 [02-user-profile.md](./02-user-profile.md#身份操作-vs-展示操作)。 > **2026-05-23**:新增 [POST /auth/me/avatar](./02-user-profile.md#post-authmeavatar) 端点。一次性的"上传头像图片 → 写库" multipart 端点,**避免下游 kungal / moyu 自己维护 image_service client**。配额从 OAuth 一侧扣;老的两步法(`PATCH /auth/me { avatar_image_hash }`)继续保留。 > **2026-05-23**:正式收录 [POST /auth/email/send-code](./02-user-profile.md#post-authemailsend-code) / [PUT /auth/email](./02-user-profile.md#put-authemail) / [PUT /auth/password](./02-user-profile.md#put-authpassword) 端点文档(以前只有口头提及)。同时把对应的错误码 10004 / 10006 / 10010-10013 补全到 [04-tokens-and-errors.md](./04-tokens-and-errors.md#认证错误-10xxx)。 > **文档拆分(2026-05-23)**:原 `api-reference.md` 拆为 4 个主题文件(01-04)。所有内容保留,按"OAuth 协议 / 用户自助 / 跨服务 / Token 与错误"四块组织。完整 OAuth 接入指南仍是单独的 [oauth-integration-guide.md](./oauth-integration-guide.md)。 --- ## 鲲 Galgame OAuth 接入 — Nuxt 项目实施提示词 · /contracts/oauth/nuxt-integration-prompt # 鲲 Galgame OAuth 接入 — Nuxt 项目实施提示词 > 本文档是给 AI 编码助手(如 Claude)的提示词,用于在 Nuxt 3/4 项目中实现 鲲 Galgame OAuth 登录功能。 > 将本文件内容作为 context 提供给 AI,它就能正确编写对接代码。 --- ## 任务 在当前 Nuxt 项目中接入 鲲 Galgame OAuth 2.0 登录系统,实现「使用 鲲 Galgame 账号登录」功能。 ## OAuth Server 信息 - **生产环境 Base URL**: `https://oauth.kungal.com/api/v1` - **开发环境 Base URL**: `http://127.0.0.1:9277/api/v1` - **协议**: OAuth 2.0 Authorization Code + PKCE (S256) ## 端点 | 端点 | 方法 | 说明 | |------|------|------| | `/oauth/authorize` | GET | 获取授权码(用户必须已登录 OAuth Server) | | `/oauth/token` | POST | 用授权码换取 token | | `/oauth/userinfo` | GET | 用 access_token 获取用户信息 | | `/oauth/revoke` | POST | 吊销 refresh_token | ## 需要实现的文件 ### 1. 环境变量 ```env # .env NUXT_OAUTH_SERVER_URL=https://oauth.kungal.com/api/v1 NUXT_PUBLIC_OAUTH_CLIENT_ID=<从管理后台获取> NUXT_OAUTH_CLIENT_SECRET=<从管理后台获取> NUXT_PUBLIC_OAUTH_REDIRECT_URI=https://www.kungal.com/auth/callback ``` 在 `nuxt.config.ts` 的 `runtimeConfig` 中配置: ```typescript runtimeConfig: { oauthServerUrl: process.env.NUXT_OAUTH_SERVER_URL, oauthClientSecret: process.env.NUXT_OAUTH_CLIENT_SECRET, public: { oauthClientId: process.env.NUXT_PUBLIC_OAUTH_CLIENT_ID, oauthRedirectUri: process.env.NUXT_PUBLIC_OAUTH_REDIRECT_URI, }, }, ``` ### 2. PKCE 工具函数(客户端) 创建 `utils/oauth-pkce.ts`,包含: - `generateCodeVerifier()` — 生成 43-128 字符随机字符串(base64url 编码) - `generateCodeChallenge(verifier)` — SHA256(verifier) 的 base64url 编码 - `generateState()` — 16 字节随机 hex 字符串 使用 `crypto.getRandomValues()` 和 `crypto.subtle.digest('SHA-256', ...)` 实现。 ### 3. 登录触发(客户端) 在登录页面添加一个「使用 鲲 Galgame 账号登录」按钮,点击后: 1. 调用 `generateCodeVerifier()` 和 `generateCodeChallenge()` 和 `generateState()` 2. 将 `code_verifier` 和 `state` 存入 `sessionStorage` 3. 构建授权 URL 并跳转: ``` {oauthServerUrl}/oauth/authorize? client_id={clientId}& redirect_uri={redirectUri}& response_type=code& scope=openid+profile& state={state}& code_challenge={codeChallenge}& code_challenge_method=S256 ``` ### 4. 回调页面(客户端) 创建 `pages/auth/callback.vue`: 1. 从 URL query 读取 `code` 和 `state` 2. 从 `sessionStorage` 读取之前保存的 `state` 和 `code_verifier` 3. 验证 `state` 一致性 4. 调用自己的服务端 API `/api/auth/oauth-callback`,传入 `code` 和 `code_verifier` 5. 成功后跳转首页,失败时跳转登录页显示错误 ### 5. 服务端回调处理 创建 `server/api/auth/oauth-callback.post.ts`: 1. 接收客户端传来的 `code` 和 `code_verifier` 2. 调用 OAuth Server 的 `/oauth/token`: ```json { "grant_type": "authorization_code", "code": "<授权码>", "redirect_uri": "<回调地址>", "client_id": "<客户端ID>", "client_secret": "<客户端密钥>", "code_verifier": "" } ``` 3. 用返回的 `data.access_token` 调用 `/oauth/userinfo` 获取用户信息: ```json // GET /oauth/userinfo, Authorization: Bearer // 响应: { "code": 0, "message": "成功", "data": { "sub": "用户UUID(唯一标识)", "name": "用户名", "email": "邮箱", "picture": "头像URL", "updated_at": 1234567890 } } ``` 4. 用 `sub`(用户UUID)在本站数据库查找或创建用户 5. 创建本站 session 6. 将 OAuth 的 `refresh_token` 安全存储(用于后续刷新) ### 6. Token 刷新 当 access_token 过期(15 分钟)时,调用: ``` POST /oauth/token { "grant_type": "refresh_token", "refresh_token": "<之前保存的>", "client_id": "<客户端ID>", "client_secret": "<客户端密钥>" } ``` **重要**:每次刷新会返回新的 `refresh_token`(令牌轮换),必须更新保存的值。 ### 7. 登出 用户在本站登出时,同时吊销 OAuth 令牌: ``` POST /oauth/revoke { "token": "" } ``` ## API 响应格式 所有 OAuth Server 的 API 响应使用统一格式: ```json { "code": 0, // 0=成功, 非零=错误码 "message": "成功", "data": { ... } } ``` 包括 `/oauth/token` 和 `/oauth/userinfo`,实际数据都在 `data` 字段中。 ## 错误处理 | 错误码 | 含义 | 处理 | |--------|------|------| | 15001 | 无效的客户端 | 检查 client_id | | 15002 | 无效的回调地址 | 检查 redirect_uri 是否已注册 | | 15003 | 无效的授权码 | 重新走授权流程 | | 15004 | PKCE 验证失败 | 检查 code_verifier 生成逻辑 | | 10003 | 令牌已过期 | 重新登录 | ## 安全要求 1. `client_secret` 只能在服务端使用(`server/` 目录下),绝不暴露到客户端 2. 始终验证 `state` 参数防止 CSRF 3. `refresh_token` 存储在 httpOnly cookie 或服务端 session 中 4. 使用 PKCE (S256) 即使有 client_secret ## 数据库 本站需要一张表关联 OAuth 用户和本站用户: ```sql CREATE TABLE oauth_accounts ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), provider VARCHAR(50) NOT NULL DEFAULT 'kun-oauth', provider_account_id VARCHAR(255) NOT NULL, -- 存 userinfo 返回的 sub (UUID) access_token TEXT, refresh_token TEXT, expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(provider, provider_account_id) ); ``` 用 `provider_account_id`(即 userinfo 的 `sub` 字段)作为用户在 OAuth 系统中的唯一标识。 --- ## 鲲 Galgame OAuth 接入指南 · /contracts/oauth/oauth-integration-guide # 鲲 Galgame OAuth 接入指南 本文档面向需要接入 鲲 Galgame OAuth 系统的第三方网站(如 kungal-nuxt、moyu-moe 等),提供完整的 OAuth 2.0 Authorization Code + PKCE 对接流程。 --- ## 1. 前置条件 ### 1.1 注册 OAuth 客户端 在 鲲 Galgame OAuth 管理后台创建 OAuth 客户端,必须正确配置以下字段(**任何一项错配都会导致 refresh 后用户被踢回登录页**): | 字段 | 说明 | 错配的后果 | |------|------|----------| | `client_id` | 系统生成的 32 字符 hex 标识符 | — | | `client_secret` | 系统生成的 64 字符 hex 密钥;**只在创建时显示一次** | 见 §1.2 决策表 | | `redirect_uris` | 允许的回调地址列表,必须**完全匹配**实际回调 URL | `invalid_redirect_uri`(15002)登录失败 | | `grants` | 允许的 grant type 列表;**必须同时勾选 `authorization_code` 和 `refresh_token`** | 没勾 refresh_token → 15 分钟后 refresh 失败 → 用户被踢 | | `is_public` | 是否公共客户端;SSR 后端 → false,浏览器 SPA → true | 见 §1.2 决策表 | | `allowed_scopes` | scope 白名单;空值默认允许 OIDC 三件套(`openid profile email`) | 请求未授权 scope → 15006 | | `refresh_token_ttl_seconds` | refresh_token 有效期;默认 90 天 | TTL 过短 → 用户被周期性踢出 | ### 1.2 confidential 还是 public? **这个决策直接影响 token 流程,错了 refresh 直接挂。** | 你的部署形态 | client 类型 | client_secret 用法 | |------|------|------| | Nuxt SSR / Go 后端代理 token(kungal、moyu 走这套)| **confidential(`is_public=false`)**| 服务端持有;每次 `/oauth/token` 必须带 | | 纯浏览器 SPA / 手机 App(galgame wiki 的 admin UI)| **public(`is_public=true`)**| **没有 secret**;改用 PKCE | **判别一句话**:浏览器看得到 token 流转 → public;只在服务端流转 → confidential。kungal / moyu 是 SSR 后端代理用户 token,**应该是 confidential**。 ### 1.3 OAuth Server 地址 ### 1.3 OAuth Server 地址 | 环境 | Base URL | |------|----------| | 开发 | `http://127.0.0.1:9277/api/v1` | | 生产 | `https://oauth.kungal.com/api/v1` | ### 1.4 端点列表 | 端点 | 方法 | 认证 | 用途 | |------|------|------|------| | `/oauth/authorize` | GET | 需要登录 | 获取授权码 | | `/oauth/token` | POST | 不需要 | 用授权码/刷新令牌换取 access token | | `/oauth/userinfo` | GET | Bearer Token | 获取用户信息 | | `/oauth/revoke` | POST | 不需要 | 吊销令牌 | | `/auth/me` | GET | Bearer Token | 获取当前用户完整资料(与 userinfo 互补:无 scope 过滤、字段更全) | | `/auth/me` | PATCH | Bearer Token | 修改 name / avatar / avatar_image_hash / bio | | `/auth/password` | PUT | Bearer Token | 修改密码(需旧密码) | | `/auth/email/send-code` + `/auth/email` | POST + PUT | Bearer Token | 修改邮箱(带验证码两步) | --- ## 2. 完整对接流程 ### 流程概览 ``` 用户点击「使用 鲲 Galgame 账号登录」 ↓ 客户端生成 PKCE code_verifier + code_challenge ↓ 重定向到 OAuth Server 的 /oauth/authorize ↓ 用户在 OAuth Server 登录(如果未登录) ↓ OAuth Server 重定向回 redirect_uri,带上 code 和 state ↓ 客户端服务端用 code 换取 access_token + refresh_token ↓ 客户端用 access_token 请求 /oauth/userinfo 获取用户信息 ↓ 完成登录 ``` > **注册流程是登录流程的超集**:用户点"注册"按钮时,跳转目标从 `/oauth/authorize?` 换成 `/auth/register?redirect=)>`。OAuth web 注册成功后会自动把用户串到 `/oauth/authorize`,第一方 client(`auto_consent=true`)跳过同意页直接发 code,剩下的流程和登录完全相同。详见 [05-registration.md](./05-registration.md)。下游可以把"登录"和"注册"两个按钮共用同一段 PKCE 生成代码,只把跳转 URL 拼接方式区分开。 --- ## 3. 详细步骤 ### 步骤 1:生成 PKCE 参数和 state ```typescript // 生成 code_verifier(43-128 字符的随机字符串) const generateCodeVerifier = (): string => { const array = new Uint8Array(32) crypto.getRandomValues(array) return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } // 根据 verifier 生成 code_challenge (S256) const generateCodeChallenge = async (verifier: string): Promise => { const encoder = new TextEncoder() const data = encoder.encode(verifier) const digest = await crypto.subtle.digest('SHA-256', data) return btoa(String.fromCharCode(...new Uint8Array(digest))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } // 生成 state(防 CSRF) const generateState = (): string => { const array = new Uint8Array(16) crypto.getRandomValues(array) return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('') } ``` ### 步骤 2:重定向到授权端点 ```typescript const codeVerifier = generateCodeVerifier() const codeChallenge = await generateCodeChallenge(codeVerifier) const state = generateState() // 保存到 session(回调时需要验证) sessionStorage.setItem('oauth_code_verifier', codeVerifier) sessionStorage.setItem('oauth_state', state) // 构建授权 URL const params = new URLSearchParams({ client_id: 'your-client-id', redirect_uri: 'https://www.kungal.com/auth/callback', response_type: 'code', scope: 'openid profile', state, code_challenge: codeChallenge, code_challenge_method: 'S256', }) // 重定向 window.location.href = `https://oauth.kungal.com/api/v1/oauth/authorize?${params}` ``` **注意**:用户在此时会被重定向到 OAuth Server。如果用户未登录,OAuth Server 会先要求用户登录,登录成功后自动重定向回你的 `redirect_uri`。 ### 步骤 3:处理回调 用户授权后,浏览器会被重定向到: ``` https://www.kungal.com/auth/callback?code=abc123...&state=xyz789... ``` 在回调页面: ```typescript // 1. 验证 state const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') const returnedState = urlParams.get('state') const savedState = sessionStorage.getItem('oauth_state') if (returnedState !== savedState) { throw new Error('State mismatch — possible CSRF attack') } // 2. 取出 code_verifier const codeVerifier = sessionStorage.getItem('oauth_code_verifier') // 3. 清理 sessionStorage.removeItem('oauth_state') sessionStorage.removeItem('oauth_code_verifier') ``` ### 步骤 4:用授权码换取令牌(服务端执行) **重要**:这一步应该在服务端完成,不要在浏览器中暴露 client_secret。 ```typescript // Nuxt 3/4 server route: /server/api/auth/callback.post.ts export default defineEventHandler(async (event) => { const { code, code_verifier } = await readBody(event) const response = await $fetch('https://oauth.kungal.com/api/v1/oauth/token', { method: 'POST', body: { grant_type: 'authorization_code', code, redirect_uri: 'https://www.kungal.com/auth/callback', client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET, code_verifier, }, }) // response 结构: // { // "code": 0, // "message": "成功", // "data": { // "access_token": "eyJhbGc...", // "token_type": "Bearer", // "expires_in": 900, // "refresh_token": "eyJhbGc...", // "scope": "openid profile" // } // } return response.data }) ``` ### 步骤 5:获取用户信息 ```typescript const userInfo = await $fetch('https://oauth.kungal.com/api/v1/oauth/userinfo', { headers: { Authorization: `Bearer ${accessToken}`, }, }) // 返回: // { // "code": 0, // "message": "成功", // "data": { // "sub": "550e8400-e29b-41d4-a716-446655440000", // 用户 UUID(唯一标识) // "name": "KUN", // "email": "kun@kungal.com", // "picture": "https://...", // "updated_at": 1234567890 // } // } ``` ### 步骤 6:在本站创建/关联用户 ```typescript // 伪代码:在你的数据库中查找或创建用户 let localUser = await db.user.findByOAuthId('kun-oauth', userInfo.sub) if (!localUser) { // 首次登录 — 创建本站用户 localUser = await db.user.create({ oauthProvider: 'kun-oauth', oauthId: userInfo.sub, // 用 sub (UUID) 作为唯一标识 name: userInfo.name, email: userInfo.email, avatar: userInfo.picture, }) } else { // 已有用户 — 可选更新信息 await db.user.update(localUser.id, { name: userInfo.name, avatar: userInfo.picture, }) } // 创建本站 session,设置 cookie 等 ``` --- ## 4. 令牌刷新 Access token 有效期 15 分钟。过期后用 refresh token 获取新的: ```typescript const response = await $fetch('https://oauth.kungal.com/api/v1/oauth/token', { method: 'POST', body: { grant_type: 'refresh_token', refresh_token: storedRefreshToken, client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET, }, }) // 返回 { code: 0, data: { access_token, refresh_token, ... } } // 必须用新的 refresh_token 替换旧的(令牌轮换) ``` **注意**:每次刷新都会返回新的 refresh_token,旧的会立即失效(token rotation)。 ### 4.1 refresh 必满足的 5 个条件 OAuth 服务端 2026 升级之后对 refresh 加了多道校验。**任何一条不通过都会拒签**,前端表现是用户登录后过一会儿(access_token 15 分钟过期触发 refresh 时)被踢回登录页。 | 条件 | 不通过返回 | 排查 | |------|----------|------| | 1. client 的 `grants` 必须包含 `refresh_token` | 400 / 15005 `ErrOAuthInvalidGrant` | 管理后台 client 编辑页,"授权类型"两个都勾上 | | 2. confidential client(`is_public=false`)必须传 `client_secret` | 400 / 15008 `ErrOAuthInvalidClientSecret` | 后端代码 body 里 `client_secret` 字段必填 | | 3. public client(`is_public=true`)**不能** 传 `client_secret`(不报错但 secret 必须为空) | — | SPA 不要泄漏 secret | | 4. 请求里的 `client_id` 必须等于**当初签发 refresh_token 时的同一个 client_id** | 401 / 10002 `ErrAuthInvalidToken` | 检查 `client_id` env 在多环境间没乱用 | | 5. refresh_token 没过期(默认 90 天,按 client 配置) | 401 / 10003 `ErrAuthTokenExpired` | 用户重新登录 | 外加两种情况: - **存量 session(升级前创建的)`client_id` 列为空**,跟条件 4 永远比不上。**这批 session 一次性必须重新登录**,登录后新 session 带正确 client_id,refresh 才正常。可以用一条 SQL 把存量清掉提前触发: ```sql DELETE FROM sessions WHERE client_id = ''; ``` - **限流把整站打爆(2026-05 修复前的典型现象)**。`/oauth/token` 曾经挂了一个 `10 次/分钟、按 IP+path` 的限流器,外加一个全局 `100 次/分钟、按纯 IP` 的限流器。 confidential SSR 客户端(kungal/moyu)在服务端代理**全站所有用户**的 token 交换 + refresh,全部来自**同一个后端 IP** —— 于是 `/oauth/token` 被限死 10 次/分钟/整站。活跃用户稍多就 `429`,下游把它当 refresh 失败 → 踢用户重登 → 重登又是一次 `/oauth/token` → 雪崩。**症状**:用户登录后约 15 分钟(access_token TTL)被踢,间歇性、与活跃度相关、`sessions` 表同一用户堆大量未过期 session。 **此问题已在 2026-05 修复**:`/oauth/token` 改为按 `client_id` 限流且额度放宽 (6000/min/client,纯防失控客户端死循环,不是反爆破);全局限流器对带 `Authorization` 头的已认证请求放行(per-IP 限流只留给匿名流量)。 接入方**无需改代码**;如果你在旧版本上遇到此现象,升级 OAuth 服务端即可。 自查 SQL: ```sql -- 同一用户是否堆了大量未过期 session(refresh 一直失败的指纹) SELECT user_id, client_id, count(*) AS n, count(*) FILTER (WHERE expires_at > now()) AS still_valid FROM sessions GROUP BY user_id, client_id HAVING count(*) > 3 ORDER BY n DESC; ``` ### 4.2 调试 refresh 401 的最小 SQL ```sql -- 查你的 client 配置(替换 your_client_id) SELECT id, name, is_public, grants, allowed_scopes, refresh_token_ttl_seconds FROM oauth_clients WHERE id = 'your_client_id'; ``` 期望值: - `is_public`:confidential 后端 `false`、SPA `true` - `grants` 包含 `refresh_token` - `allowed_scopes` 含 `openid profile email`(按需) - `refresh_token_ttl_seconds` ≥ 86400(1 天,太短会被周期性踢) 如果 `grants = '["authorization_code"]'` 是常见的升级遗留 bug,一条 SQL 修: ```sql UPDATE oauth_clients SET grants = '["authorization_code","refresh_token"]'::jsonb WHERE id = 'your_client_id'; ``` 或者重跑 OAuth 端的 `go run ./cmd/migrate` —— 它包含自动 backfill。 ### 4.3 多站本地共用 Redis / 同域导致跨站 session 串台 > **2026-05 实战定位的真实事故。** 现象与"refresh 失败被踢"完全一样,但 > 根因不在 OAuth 端 —— OAuth 的拒绝是**正确**的。接入方(尤其本地 dev > 同时跑两个站点)必看。 **现象**:用户登录后过一会被踢回登录页,间歇性,且**在一个站点的操作会把 另一个站点也登出**。OAuth 端日志可见: ``` WARN oauth refresh reject stage=client_id_mismatch request_client_id=<站点 A 的 client> session_client_id=<站点 B 的 client> ``` **根因**:两个下游站点(如 kungal + moyu)满足以下**全部**条件时, session 在它们之间串台: | 维度 | 串台条件 | |------|---------| | Host | 都在 `127.0.0.1`(本地 dev)。**Cookie 按域名隔离,不区分端口** —— `127.0.0.1:2333` 设的 cookie 会发给 `127.0.0.1:5214` | | Cookie 名 | 两站都用同一个名字(如 `kun_session`) | | Redis | 共用同一实例 + 同一 DB | | Redis key 前缀 | 两站都用同一前缀(如 `session:`) | 链路:站点 B 登录 → 浏览器存 `kun_session=X`(host=127.0.0.1,全端口共享) → 用户访问站点 A → 浏览器把同一个 cookie 发给 A → A 读共享 Redis 的 `session:X`(实际是 B 的 session,refresh_token 由 B 的 client 签发) → A 用**自己的 client_id** 去刷 **B 签发的 refresh_token** → OAuth 正确拒绝 `client_id_mismatch`(10002) → A 判定 token 死亡,从**共享 Redis 删掉** `session:X`(连带把 B 也登出) → 用户被踢。 > 生产环境 `kungal.com` 与 `moyu.moe` 是不同注册域,cookie 不串;但 > **共用 Redis + 同 key 前缀**在生产若共用 Redis 仍是隐患。 **自查**:在共享 Redis 上看是否多站的 session 落在同一 keyspace: ```bash redis-cli --scan --pattern 'session:*' | head # 同前缀 = 危险信号 ``` 确认 OAuth 端 `sessions` 表里同一用户是否堆了大量未过期 session (refresh 一直失败的指纹): ```sql SELECT user_id, client_id, count(*) AS n FROM sessions GROUP BY user_id, client_id HAVING count(*) > 3 ORDER BY n DESC; ``` **修复(在下游站点,不在 OAuth)—— 让两站 session 命名空间互不相交**: 1. **Cookie 名按站点唯一**(必须,根治):`kungal_session` / `moyu_session` 2. **Redis key 前缀按站点唯一**(建议,纵深防御):`kungal:session:` / `moyu:session:`;或用不同 `REDIS_DB` 把 cookie 名 / key 前缀收敛成常量后集中改值,避免漏掉硬编码调用点。改完 重启下游服务;存量用户需**重新登录一次**(旧 cookie 不再被读取),旧 `session:*` 孤儿 key 按 TTL 自然过期。 ### 4.4 SSR 并发刷新:锁失败者必须"等赢家",不能当失败踢人 > **2026-05 实战定位。** 现象同样是"登录后过一会被踢",但根因既不在 > OAuth 端、也不是 §4.3 的串台 —— 是下游自己的刷新单飞锁实现,把 > "锁竞争"误判成"刷新失败"。 **现象**:站点 A(如 kungal)正常,结构几乎相同的站点 B(如 moyu)一直被 踢;且**间歇、与活跃度相关**,访问越频繁越容易中。OAuth 端日志**干净** (refresh 都 200),下游日志大量 `refresh failed; rejecting request`。 **根因**:下游用 `SETNX lock:refresh:` 做"同一 session 同一时刻只刷 一次"的单飞锁。SSR 站点一个页面会扇出 N 个并发 API 请求,access_token 在第 15 分钟硬过期那一刻,N 个请求同时进 auth 中间件、同时判定需要刷新: ``` N 个并发请求 ├─ 1 个 SETNX 抢到锁 → 调 /oauth/token 刷新成功 → 写回 session └─ N-1 个 SETNX 失败(锁被占) ↓ 错误实现:把"锁竞争"当成刷新失败 → clearSessionCookie + 返回 205/401 → 浏览器收到 N-1 个删 cookie 响应 → cookie 没了 → 重新登录 ``` 赢家其实刷成功了,但用户的浏览器已经被 N-1 个响应清掉了 session cookie。 **正确做法(锁失败者要"等赢家",对齐另一个能用的站点)**: 1. 刷新函数对**锁竞争**返回一个**可识别的 sentinel error**(别和真失败 混在一个匿名 error 里)。 2. 调用方拿到该 sentinel → **不要清 cookie / 不要踢**,转而**轮询 Redis** (上限 ~3s、间隔 ~100ms)等赢家把新 session 写回(用 `OAuthExpiresAt` 是否前进判断),刷好就拿新 token 正常放行。 3. 等待超时或赢家把 session 删了(= OAuth 永久拒绝)才失败: - **永久**(Redis session key 已被删)→ 清 cookie + 让用户重登 - **瞬时 / 等待超时**(key 还在)→ **保留 cookie**,返回可重试错误, 下次请求自动重试(赢家几乎都 sub-second 完成) > 反模式自查:搜下游 auth 中间件,凡是 `SETNX` / `SetNX` 失败分支后面 > 直接 `clearCookie` + `return 401/205` 的,就是这个 bug。对照那个"正常 > 的站点"的锁失败者分支——它应该是个 poll-wait 循环,不是立即失败。 > 这也顺带消除"OAuth 网络抖动/5xx 也把人踢了"的次级问题:只在 > **确知永久失败**(Redis session 已不存在)时才清 cookie,其余一律保留 > 留给下次重试。 --- ## 5. 令牌吊销(登出) 用户在你的网站登出时,应该吊销 OAuth 令牌: ```typescript await $fetch('https://oauth.kungal.com/api/v1/oauth/revoke', { method: 'POST', body: { token: storedRefreshToken, }, }) // 遵循 RFC 7009,无论令牌是否有效,始终返回 200 OK ``` --- ## 6. JWT Access Token 结构 如果你需要在不调用 userinfo 端点的情况下解析用户信息,可以直接解码 JWT: ```json { "sub": "550e8400-e29b-41d4-a716-446655440000", "email": "kun@kungal.com", "name": "KUN", "roles": ["user", "admin"], "exp": 1700000000, "iat": 1699999100, "nbf": 1699999100 } ``` - **签名算法**:HS256 - **有效期**:15 分钟 - **重要**:不要在客户端验证签名(你没有 JWT secret),仅用于读取 claims。需要验证时请调用 `/oauth/userinfo`。 --- ## 7. 错误处理 所有 API 响应格式: ```json { "code": 0, "message": "成功", "data": { ... } } ``` `code = 0` 表示成功,非零表示错误。 ### OAuth 相关错误码 | code | HTTP | 含义 | 触发场景 / 处理方式 | |------|------|------|-------------------| | 10001 | 401 | 未授权 | 缺 Bearer Token;前端跳登录 | | 10002 | 401 | 无效的令牌 | refresh_token 不存在、或与 session.client_id 不匹配(详见 §4.1 条件 4);前端走完整登录 | | 10003 | 401 | 令牌已过期 | refresh_token 已过期;前端走完整登录 | | **10014** | **403** | **账号已封禁** | **用户被 admin 封号;前端应跳错误页而非登录页(再登也无用)** | | 15001 | 400 | 无效的客户端 | client_id 不存在 | | 15002 | 400 | 无效的回调地址 | redirect_uri 未注册 | | 15003 | 400 | 无效的授权码 | code 已过期 / 已用 / 并发兑换时输的那次;让用户重新登录 | | 15004 | 400 | 无效的代码验证器 | PKCE code_verifier 不匹配 | | 15005 | 400 | 无效的授权类型 | client 的 `grants` 不允许这个 grant_type(**最常见:refresh_token 没勾**),见 §4.1 条件 1 | | 15006 | 400 | 无效的 scope | 请求的 scope 不在 client 的 `allowed_scopes` 内 | | 15008 | 400 | 无效的 client secret | confidential client 漏传或填错 secret,见 §4.1 条件 2 | | 15009 | 400 | 需要 PKCE | public client 没传 code_verifier | --- ## 8. Nuxt 3/4 完整接入示例 ### 8.1 环境变量 ```env # .env OAUTH_SERVER_URL=https://oauth.kungal.com/api/v1 OAUTH_CLIENT_ID=your-client-id OAUTH_CLIENT_SECRET=your-client-secret OAUTH_REDIRECT_URI=https://www.kungal.com/auth/callback ``` ### 8.2 登录按钮组件 ```vue ``` ### 8.3 回调页面 ```vue ``` ### 8.4 服务端回调处理 ```typescript // server/api/auth/oauth-callback.post.ts export default defineEventHandler(async (event) => { const { code, code_verifier } = await readBody(event) const config = useRuntimeConfig() // 1. 用授权码换取 token const tokenResponse = await $fetch(`${config.oauthServerUrl}/oauth/token`, { method: 'POST', body: { grant_type: 'authorization_code', code, redirect_uri: config.public.oauthRedirectUri, client_id: config.public.oauthClientId, client_secret: config.oauthClientSecret, code_verifier, }, }) // 2. 获取用户信息 const userInfoResp = await $fetch(`${config.oauthServerUrl}/oauth/userinfo`, { headers: { Authorization: `Bearer ${tokenResponse.data.access_token}` }, }) const userInfo = userInfoResp.data // 3. 在本站创建/查找用户(根据你的数据库逻辑) // ... // 4. 创建本站 session // ... // 5. 保存 OAuth refresh_token 以便后续刷新 // ... return { success: true } }) ``` --- ## 9. 安全注意事项 1. **client_secret 只能在服务端使用**,绝不能暴露到前端代码 2. **始终使用 PKCE**(S256 方法),即使你有 client_secret 3. **始终验证 state 参数**,防止 CSRF 攻击 4. **存储 refresh_token** 时使用 httpOnly cookie 或加密存储 5. **令牌轮换**:每次刷新后用新的 refresh_token 替换旧的 6. **CORS**:生产环境已配置 `kungal.com` 和 `moyu.moe`,其他域名需要在 OAuth Server 管理后台添加 --- ## 10. 后端跨服务用户回拉(kungal / moyu / galgame_wiki) OAuth 是单一用户身份源(single source of truth)。kungal / moyu / galgame_wiki 等业务库 **不再缓存** `users.name` / `users.avatar` 等字段,只保留 `user_id` 外键。 渲染列表时按需从 OAuth 批量拉取。 ### 10.1 端点 | 端点 | 用途 | |------|------| | `GET /users/batch?ids=1,2,3` | 按 ID 批量回拉用户 brief,渲染列表/评论用 | | `GET /users/search?q=kun&limit=10` | 按用户名搜索(精确 > 前缀 > 子串),@提及/搜索框用 | 详见 [api-reference.md](./api-reference.md)。两个端点共用 OAuth Client Basic Auth,响应都不含 email / moemoepoint 等隐私字段。 - `/users/batch`:单次最多 100 个 ID - `/users/search`:q 长度 1..50,limit 默认 20、封顶 50 - 通过 migrate-users 后,kungal / moyu 中的 `*_user_id` 已与 OAuth `users.id` 对齐 ### 10.2 客户端实现 OAuth 这边**不发布 SDK 代码** —— API 是契约,每个 consumer 自己实现一个薄客户端。原因和实现指南详见: > [docs/migration/user/08-downstream-integration.md §4 客户端实现指南](../../migration/user/08-downstream-integration.md#4-客户端实现指南) 文档里有: - **L1 最小实现**(30-50 行 Go 代码,可直接复用)—— 适合脚本、低 QPS 后台 - **L2 加 TTL 缓存**(+30 行)—— 中频后端服务 - **L3 加 singleflight + 负缓存 + 分片**(+50 行)—— 高并发 HTTP 服务 - 各级对应的工作负载特征 + 升级时机判断 ### 10.3 渲染管线建议 1. **DB 查询**:业务表只 `SELECT ..., user_id FROM ...`,不 JOIN 用户表 2. **收集 ID**:把列表里所有 `user_id` 收成 `[]uint`(去重) 3. **批量回拉**:客户端的 `Users(ctx, ids)` 一次调用拿齐 4. **拼装**:在 service / handler 层把 user brief 注入到响应 DTO **N+1 防护**:永远批量拉。不要在循环里调单个 user 接口 —— 即使有缓存命中,miss 时仍然是 N 次 HTTP 请求。 ### 10.4 失效策略 OAuth 端用户改名 / 换头像 / 被封禁时,下游服务的缓存最多滞后客户端配置的 TTL 时间。 对一致性要求严格的场景: - 短 TTL(30s–2min),靠时间到期被动刷新 - 或在 OAuth 侧广播 `user.updated` 事件,下游订阅后失效本地缓存(**当前未规划**,需要时再加) - 鉴权决策(roles)直接解 JWT claim,不走 OAuth RPC —— 永远即时