---
url: /docs/api-docs/v1/oauth/security.md
---
# 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 单飞](/api-docs/v1/oauth/sdk)。

```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 代理）
