Appearance
OAuth 安全规范(前端对接义务)
📍 来源:OAuth 文档
security.html服务端安全机制:RS256 双密钥签名 / AES-256-GCM 存储加密 / jti 一次性轮换 / Redis 滑窗限流 本文讲前端对应的义务。
1. 双 token 的本质分工
| token | 有效期 | 用途 | 存储要求 |
|---|---|---|---|
access | 2h | 每个业务请求的 Authorization: Bearer | 内存变量为主(不落盘) |
refresh | 30d | 仅用于 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/agreementhttps://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 代理)