文档索引

OAuth 2.0 身份绑定系统

raricy.com 作为 OAuth 2.0 Authorization Server,让外部第三方应用以标准协议读取 raricy 用户的基础资料(id / username / avatar)。

版本范围:v1 仅支持 profile scope,不开放任何写操作;access_token 长期有效(90 天),不发放 refresh_token。


1. 协议

  • Grant Type:authorization_code(RFC 6749 §4.1)
  • 客户端鉴权:client_secret_basic(HTTP Basic)优先,body 内 client_id + client_secret 兜底
  • Scope:profile(v1 唯一可用)
  • Access Token TTL:90 天
  • Refresh Token:v1 不发放;吊销走 /api/oauth/revoke

2. 端点

端点 方法 鉴权 说明
/oauth/authorize GET raricy session 授权确认页(浏览器)
/api/oauth/authorize POST raricy session 用户点「同意」后 mint code,返回 200 + {redirect_to}(由前端做顶层跳转,不是 302)
/api/oauth/token POST client (HTTP Basic / body) code → access_token
/api/oauth/userinfo GET Authorization: Bearer 返回 {sub, username, avatar_url}
/api/oauth/revoke POST session 或 bearer 吊销 token(RFC 7009)
/api/oauth/connections GET raricy session 当前用户已绑定的应用列表(一应用一行)
/api/oauth/connections/[applicationId] DELETE raricy session 解除与该应用的绑定(撤销其全部令牌)
/api/admin/oauth/applications GET / POST owner 列出 / 创建应用
/api/admin/oauth/applications/[id] PATCH / DELETE owner 更新 / 软禁用

2.1 绑定列表的粒度是「应用」,不是「令牌」

v1 不发放 refresh_token,因此每次走完授权流程都会新签一条 90 天令牌。若外部应用 在每次用户登录时都跳一遍 /oauth/authorize,同一用户在同一应用名下就会积累多条存活令牌。

对应地,/api/oauth/connections 按 applicationId 聚合——同一个应用只出现一行, 另给 tokenCount(该应用名下的存活令牌数,>1 即重复授权过)与 lastAuthorizedAt。

DELETE /api/oauth/connections/[applicationId] 是整应用解绑:撤销该用户名下该应用 的全部存活令牌。这是刻意的——按钮语义是「解除与 X 的绑定」,只吊销一条会留下仍然 有效的凭证,属于静默越权。重复点击幂等(返回 revokedCount: 0)。

另一条路(重复授权时自动吊销旧令牌)没有采用:外部应用可能在多个实例/设备上各存 一份令牌,静默吊销会让没重新授权过的那个实例突然 401。聚合显示对第三方零影响。


3. 注册流程

应用必须由站长登记。两种方式:

3a. CLI(推荐自动化 / 脚本场景)

npm run cli -- oauth create-app "cattca-game" \
  --homepage "https://cattca.example.com" \
  -d "CattCa 站点的用户绑定" \
  --redirect-uri "https://cattca.example.com/oauth/callback"

# 输出:
#   client_id:     AbCdEf123...
#   client_secret: XyZ_987...   ← 仅此一次

3b. 管理页(人工 / 临时调整)

  1. 用站长账号登录
  2. 访问 /admin/oauth
  3. 填写名称、说明、主页、回调 URI(一行一个)
  4. 点击「创建」→ 弹窗显示 client_id + client_secret → 立即复制保存

禁用 / 启用:同页「已注册的应用」列表里点击对应按钮。


4. 集成示例(curl)

# 0) 应用信息
CLIENT_ID="AbCdEf123..."
CLIENT_SECRET="XyZ_987..."
REDIRECT_URI="https://cattca.example.com/oauth/callback"

# 1) 把用户引导到 raricy 授权页
#    (如果未登录会先跳 /login;登录后会带 next= 跳回此 URL)
AUTH_URL="https://raricy.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("'$REDIRECT_URI'", safe=""))')&state=xyz&scope=profile"
# 在浏览器或 302 跳到 AUTH_URL

# 2) 用户同意 → 外部应用收到 redirect:?code=XXX&state=xyz
CODE="用户授权后从回调 URL 中获取的 code"

# 3) 用 code 换 access_token
TOK_RES=$(curl -sS -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=$CODE&redirect_uri=$(python3 -c 'import urllib.parse; print(urllib.parse.quote("'$REDIRECT_URI'", safe=""))')" \
  https://raricy.com/api/oauth/token)
# 响应:{"access_token":"...","token_type":"Bearer","expires_in":7776000,"scope":"profile"}
ACCESS_TOKEN=$(echo "$TOK_RES" | jq -r .access_token)

# 4) 读取用户资料
curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" https://raricy.com/api/oauth/userinfo
# 响应:{"sub":"<user_id>","username":"...","avatar_url":"https://raricy.com/api/avatar/<user_id>"}

# 5) 用户主动解除(在 raricy 网站 settings 页) 或应用替用户登出:
curl -sS -X POST -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"token\":\"$ACCESS_TOKEN\"}" \
  https://raricy.com/api/oauth/revoke
# 200 {} — 之后该 token 在 /userinfo 立即返回 invalid_token

5. userinfo 响应 schema

{
  "sub":        "8a5268d8-abff-4241-bb57-bc526229cf00",   // OIDC 风格的稳定用户 ID
  "username":   "oauth_test_1vi7nn",                       // raricy 用户名
  "avatar_url": "https://raricy.com/api/avatar/8a5268d8-..." // null 表示未设头像(用占位图)
}

被封号(isBanned = true)用户的 token 立即失效(userinfo 返回 error: invalid_token)。


6. 安全说明

项 实现
Token 存储 DB 仅存 SHA-256 哈希作为 PK;原始 token 仅在响应里出现一次
client_secret 存储 werkzeug 兼容 scrypt(自带盐),与 User.passwordHash 同款
redirect_uri 校验 精确字符串相等(OAuth 2.0 Security BCP §4.1;无通配 / 前缀 / 子串)
客户端鉴权 HTTP Basic 优先(RFC 6749 §2.3.1)
授权码单次使用 Prisma 原子 updateMany where {codeHash, applicationId, redirectUri, usedAt: null, expiresAt: {gt: now}} —— 五个条件全中才消费,恰好一次,且绑定到当初那对 client / redirect_uri
授权码 TTL 10 分钟
redirect_uri 一致性 token 端再次校验与授权时一致(防 code 截获重定向)
Token 比较时序 用 SQL PK 存在性查询;client_secret 走 timingSafeEqual
CSRF /api/oauth/authorize 保留;/token /userinfo /revoke 豁免(client_secret 鉴权)
限频 authorize 30/min/user · token 60/min/clientId · userinfo 600/min/user
日志 原始 token / code / secret 永不写入日志

SECRET_KEY 轮换不影响 client_secret(scrypt 自带盐),但需注意:轮换 SECRET_KEY 不会让存量 client_secret 失效,也不影响 OAuth 服务。


7. 限频

复用 src/lib/rate-limit.ts 的进程内桶(单进程语义;桶随清扫落盘,重启不重置窗口)。 多实例部署时建议改 Redis。

⚠️ 配额值不在 src/lib/rate-limit.ts 的 RULES 里 —— 下表这三条是各 route 里 内联的字面量(oauth/{authorize,token,userinfo}/route.ts 各自 import rateLimit() 后 就地传 { limit, windowMs })。改 OAuth 限频要去那三个 route 改,翻 RULES 是找不到的; 反过来,做全站限频审计时也别漏掉它们。其余所有接口的配额才在 RULES。

Key 格式 限制
oauth:authorize:${userId} 30 / 分钟
oauth:token:${clientId} 60 / 分钟
oauth:userinfo:${userId} 600 / 分钟

8. v1 限制与未来扩展点

现状 未来扩展
仅 profile scope 加 email / notifications 等读 scope;写 scope 需配合更严的 consent UI
无 refresh_token 加 grant_type=refresh_token(v1 用 revocation + 长 TTL 兜底)
软禁用(disabledAt) 硬删除(FK CASCADE 已就位)
站长手工注册 自助申请 + admin 审批流
站内吊销走 settings 页 加 /api/oauth/revoke 站外调用方接口(已实现,但仅 owner + self)
仅 HTTP / HTTPS redirect 加自定义 scheme 支持(mobile app)
单站点 cookie 加 PKCE(RFC 7636)防 code 截获 + 适配 SPA / mobile
state 仅透传 加 server-side state 校验防 CSRF on /oauth/authorize GET

9. 端到端验证脚本

tests/oauth-e2e.sh —— 手工跑的全流程验证,11 项断言,覆盖的多是单测不方便打的 协议安全属性:授权码单次使用、redirect_uri 精确匹配、错 client_secret → 401、 revoke 后 userinfo 失效、RFC 7009 未知 token 也回 200(不泄露存在性)、 以及跨域 Origin 调 /api/oauth/token 印证 CSRF 豁免生效。

前置:dev server 在跑、库内有一个测试用户与一个 OAuth 应用(client_id / secret 从 §3a 的 CLI 输出拿)。用法见脚本头部注释(它把参数、运行方式都写在里面了)。

它不在 npm test 里 —— 需要真实服务与库内数据,属手工验证工具, 与 npm run smoke 同一类。改了 OAuth 的端点行为之后建议跑一遍。

本页内容来自仓库 docs/oauth.md·在 GitHub 上查看