Skip to content

OAuth 安全规范(前端对接义务) ​

📍 来源:OAuth 文档 security.html 服务端安全机制:RS256 双密钥签名 / AES-256-GCM 存储加密 / jti 一次性轮换 / Redis 滑窗限流 本文讲前端对应的义务。


1. 双 token 的本质分工 ​

token有效期用途存储要求
access2h每个业务请求的 Authorization: Bearer内存变量为主(不落盘)
refresh30d仅用于 rotate 换新双 token最高级别安全存储(Android KeyStore / iOS Keychain)

Why access 不落盘:

  • access 泄露窗口只有 2h
  • 放内存把损失压到最小
  • refresh 才是需要跨启动保留的「会话根」

2. refresh_token 存储(Android KeyStore) ​

参考实现:apps/mp-official/utils/oauth/storage.ts(v2 格式)

Android:plus.android 桥 → AndroidKeyStore AES-256-GCM
         · 256 位密钥由系统生成、不可导出(硬件-backed)
         · 每次加密随机 12 字节 IV,存储格式 v2: base64(iv ‖ ciphertext)
降级:无 KeyStore 环境 → XOR 混淆(v1 兼容读,保存自动升级 v2),全回退不阻断登录

2.1 禁止事项 ​

  • ❌ 明文写 localStorage / SharedPreferences / 临时文件
  • ❌ 放在 URL、日志、埋点里
  • ❌ 用 access 换 refresh 的任何自造接口(不存在此流程)

2.2 真机验证清单 ​

  • [ ] Android KeyStore 在真机生效(HBuilderX 标准基座可能无原生桥 → 自动降级)
  • [ ] 自定义基座验证 KeyStore AES-256-GCM 加密
  • [ ] 降级路径(XOR)有 console.warn 日志
  • [ ] 卸载 App 后 KeyStore 失效(符合预期)

3. jti 一次性轮换 —— rotate 的铁律 ​

每次 rotate 成功后:旧 refresh 的 jti 立即作废,响应中的新 refresh 必须立刻覆盖本地存储。

rotate 失败(40101001)→ 旧 refresh 已被消费或作废
  → 重试必然再失败(重放 = 盗用特征)
  → 唯一正确动作:清空本地会话 → 登录页

3.1 并发场景必须做单飞 ​

同一时刻仅一个 in-flight rotate,其余排队等结果 —— 完整代码见 SDK 参考 · 401 单飞。

typescript
// SDK 已内置
async rotate(): Promise<RotateResult> {
  if (this.inflightRotate) return this.inflightRotate; // 并发请求共享同一续期
  this.inflightRotate = this.doRotate().finally(() => (this.inflightRotate = null));
  return this.inflightRotate;
}
// doRotate 失败(40101001)→ 上层捕获后 clearTokens + 跳登录,绝不重试

3.2 禁用反模式 ​

typescript
// ❌ 禁止:失败重试 rotate
async function badRotate() {
  try {
    await client.rotate();
  } catch {
    await client.rotate();  // ⚠️ 重放 = 盗用特征
  }
}

// ❌ 禁止:失败时静默继续用旧 token
async function badAuth() {
  try {
    return await client.rotate();
  } catch {
    return null;  // 业务层拿到 null 应立即跳登录,不能继续
  }
}

4. token_expiring 主动续期 ​

登录/rotate/logout 等响应都带 token_expiring: boolean(access 剩余 < 30min 时为 true)。

typescript
if (res.data.token_expiring) {
  idleQueue.enqueue(() => oauth.rotate()); // 空闲时静默续期,用户无感
}

Why:

  • 不做也能跑(401 兜底 rotate),但会造成首次 401 延迟
  • 建议做:用户体验更平滑

4.1 实现要点 ​

typescript
client.onTokenExpiring = () => {
  setTimeout(() => rotateOnce(), 2000); // 空闲 2s 后静默续,避免抢当前请求带宽
};

5. 传输与网络 ​

  • 全链路 HTTPS(生产 api.qingmulife.cn,TLS 由 nginx 终结)
  • dev 环境的 http 明文仅限本机调试,生产包严禁放开明文白名单
  • 不在 URL query 传 token(只能放 header)
  • 日志/埋点里必须脱敏(打印 token 前 8 位即可)

5.1 token 日志脱敏示例 ​

typescript
// ✅ 推荐
console.log(`access: ${access.slice(0, 8)}...`);
console.log(`refresh: ${refresh.slice(0, 8)}...`);

// ❌ 禁止
console.log(`access: ${access}`);
console.log(`full token: ${access} ${refresh}`);  // 埋点会泄露

5.2 生产部署清单 ​

  • [ ] manifest.json 无 http 明文白名单残留
  • [ ] BASE_URL 走生产域名(grep 确认无 localhost)
  • [ ] 全链路 HTTPS(TLS 1.2+)
  • [ ] nginx 配置 HSTS

6. 验证码与协议 ​

6.1 图形验证码 ​

  • 一次一消费、绑定生成 IP /24 段、120s TTL
  • 前端任何失败分支后重新拉取,不做本地缓存复用

6.2 用户协议与隐私政策 ​

  • agreementAccepted: true 必须来自用户真实勾选动作
  • 协议版本与勾选留痕由服务端记录
  • 前端伪造勾选 = 替用户承担合规风险(法律责任)

6.3 协议文案链接 ​

  • https://api.qingmulife.cn/agreement
  • https://api.qingmulife.cn/privacy

生产已部署,可 web-view 加载。


7. 账号注销(区别于 logout) ​

接口范围入口
oauth action logout仅清会话(device 或 all)OAuth 集合
user.deleteAccount删数据(用户 + 关联)业务 API 集合(OAuth 之外)

⚠️ 两个入口不要混淆:

  • 用户点「退出登录」→ logout scope=device
  • 用户点「注销账号」→ user.deleteAccount(独立接口,需二次确认 + 7 天冷静期)

7.1 注销前置检查(参见 user.deleteAccount) ​

  • 用户积分 > 0 → blocker(需先消耗)
  • 会员未到期 → blocker(需等到期或放弃)
  • 已有订单未完成 → blocker(需先完结)
  • 二次确认 + 短信验证码

8. 安全检查清单(前端提测前必过) ​

  • [ ] refresh 走 KeyStore 加密存储
  • [ ] access 不落盘(仅内存变量)
  • [ ] rotate 单飞实现(并发 401 只发一次续期)
  • [ ] rotate 失败即清会话 + 跳登录(无重试循环)
  • [ ] token_expiring=true 触发空闲续期
  • [ ] 协议未勾选时登录按钮禁用(前端拦截 40101008)
  • [ ] 图形验证码失败分支全部「重新拉取」
  • [ ] 日志/埋点里 token 仅前 8 位
  • [ ] 生产包无 http 明文白名单
  • [ ] 全链路 HTTPS(含 dev nginx 代理)