Files
raisfast/docs/user-auth-unified.md
T
2026-05-11 14:59:02 +08:00

896 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 统一用户认证系统设计
> raisfast 通用后端平台 — 用户注册/登录/绑定/解绑重构方案。
> 替代当前"email NOT NULL + 哨兵值"的打补丁方案。
---
## 1. 设计原则
| 原则 | 说明 |
|------|------|
| **账户与凭证分离** | `users` 表只存 profile,登录凭证独立存 `user_credentials` |
| **无哨兵值** | 不用 `!sms:`、`!oauth:` 之类的假数据填充 NOT NULL 列 |
| **可扩展** | 新增登录方式只需加一个 `auth_type` 枚举值,不改表结构 |
| **多凭证共存** | 一个用户可以同时绑定邮箱密码、手机号、多个 OAuth provider |
| **安全解绑** | 至少保留一个有效凭证,防止用户锁死自己 |
---
## 2. 表结构变更
### 2.1 `users` 表(精简 — 只保留 profile)
```sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE, -- UUID v7,对外 ID
tenant_id TEXT NOT NULL DEFAULT 'default',
username TEXT UNIQUE NOT NULL, -- 唯一用户名
display_name TEXT,
avatar TEXT,
bio TEXT,
website TEXT,
slug TEXT UNIQUE,
locale TEXT,
role TEXT NOT NULL DEFAULT 'reader',
status TEXT NOT NULL DEFAULT 'active', -- active / suspended / deleted
registered_via TEXT NOT NULL, -- 'email' | 'phone' | 'oauth_github' | ... 注册时写入,不再变更
-- 移除: email, password_hash, phone, email_verified
-- 这些全部迁移到 user_credentials 表
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_slug ON users(slug) WHERE slug IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
```
**移除的字段:**
| 字段 | 原因 | 去向 |
|------|------|------|
| `email` | 不是所有用户都有 email | `user_credentials.identifier`(auth_type=email) |
| `password_hash` | 不是所有用户都有密码 | `user_credentials.credential_data`(auth_type=email) |
| `phone` | 不是所有用户都有手机号 | `user_credentials.identifier`(auth_type=phone) |
| `email_verified` | 验证状态跟凭证走 | `user_credentials.verified` |
**新增字段:**
| 字段 | 说明 |
|------|------|
| `registered_via` | 用户最初注册方式(如 `email`、`oauth_github`),注册时写入,永不变更。用于引导流程、安全审计、产品分析 |
### 2.2 `user_credentials` 表(新增 — 所有登录凭证)
```sql
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
auth_type TEXT NOT NULL, -- 凭证类型(见下方枚举)
identifier TEXT NOT NULL, -- 登录标识符
credential_data TEXT NOT NULL, -- JSON: 凭证数据(密码hash、公钥、OAuth token等)
verified INTEGER NOT NULL DEFAULT 0, -- 是否已验证
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(auth_type, identifier) -- 同类型同标识符唯一
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type_id ON user_credentials(auth_type, identifier);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(auth_type);
```
**核心设计:`credential_data` 是 JSON 字段。** 每种 `auth_type` 定义自己的 JSON schema,表结构永远不需要改。
### 2.3 `auth_type` 枚举值及 `credential_data` schema
| auth_type | identifier | credential_data | 说明 |
|-----------|-----------|----------------|------|
| `email` | email 地址 | `{"password_hash": "$argon2id$..."}` | 邮箱密码登录 |
| `phone` | 手机号 | `{}` | 手机验证码登录 |
| `oauth_github` | `github:{provider_user_id}` | `{"email": "a@b.com", "display_name": "Alice"}` | GitHub OAuth |
| `oauth_google` | `google:{provider_user_id}` | `{"email": "a@b.com", "display_name": "Alice"}` | Google OAuth |
| `oauth_wechat` | `wechat:{openid}` | `{"union_id": "..."}` | 微信 OAuth |
| `passkey` | WebAuthn credential ID | `{"public_key": "base64...", "sign_count": 0, "transports": ["usb","ble"]}` | FIDO2/WebAuthn |
| `ldap` (预留) | DN 或用户名 | `{"dn": "cn=alice,ou=users,dc=corp"}` | 企业 LDAP |
| `saml` (预留) | NameID | `{"issuer": "https://idp.corp", "name_id": "alice"}` | 企业 SSO |
**新增登录方式**:只需定义一个新的 `auth_type` 字符串 + 对应的 `credential_data` JSON schema,表结构永远不改。
**设计原则:**
- 固定列只有 `auth_type`、`identifier`、`credential_data`、`mfa_data`
- 所有 auth_type 特有的数据全部放 JSON
- 新增登录方式 = 新增一行代码里的 enum variant + 新的 service 函数
- 不需要 ALTER TABLE
### 2.4 `oauth_accounts` 表(保留但精简)
`oauth_accounts` 表保留用于存储 OAuth token 等Provider特有的信息,与 `user_credentials` 为一对一关系:
```sql
-- 保留现有 oauth_accounts 表结构不变
-- 新增 user_credentials 后,oauth_accounts.user_id 与 credentials.user_id 对齐
-- oauthAccounts 仍负责存储 access_token / refresh_token / profile 等OAuth专属数据
```
### 2.5 其他表不变
- `refresh_tokens` — 不变
- `password_reset_tokens` — 不变
- `sms_codes` — 不变
- `email_verification_tokens` — 不变
- `oauth_states` — 不变
---
## 3. 数据模型
### 3.1 Rust Model
```rust
// src/models/user.rs — 精简后的 User
pub struct User {
pub id: i64,
pub document_id: String,
pub tenant_id: Option<String>,
pub username: String,
pub display_name: Option<String>,
pub avatar: Option<String>,
pub bio: Option<String>,
pub website: Option<String>,
pub slug: Option<String>,
pub locale: Option<String>,
pub role: String,
pub status: String,
pub registered_via: String, // 'email' | 'phone' | 'oauth_github' | ...
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
// src/models/user_credential.rs — 新增
pub struct UserCredential {
pub id: i64,
pub document_id: String,
pub user_id: i64,
pub auth_type: String, // "email" | "phone" | "oauth_github" | ...
pub identifier: String, // email / phone / "github:12345"
pub credential_data: String, // JSON: { "password_hash": "..." } 或 {} 或 { "public_key": "..." }
pub verified: i64, // 0 = 未验证, 1 = 已验证 (SQLite INTEGER)
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
```
### 3.2 CreateUserCmd 变更
```rust
// 之前
pub struct CreateUserCmd {
pub email: String, // NOT NULL,被迫填哨兵值
pub username: String,
pub password_hash: String, // NOT NULL,被迫填哨兵值
}
// 之后
pub struct CreateUserCmd {
pub username: String,
pub registered_via: String, // 'email' | 'phone' | 'oauth_github' | ...
}
// 凭证由各 service 独立通过 user_credential::create() 创建
// 不需要在 CreateUserCmd 中内嵌凭证信息
```
---
## 4. 注册流程
### 4.1 邮箱密码注册
```
POST /api/v1/auth/register
Body: { "username": "alice", "email": "alice@example.com", "password": "Secret123!" }
1. 检查 config.registration_email_enabled == true
2. 校验 email 格式、密码强度、username 唯一
3. 事务:
a. INSERT users (username, role='reader')
b. INSERT user_credentials (auth_type='email', identifier=email, credential_data=json(password_hash))
4. 如果 config.require_email_verification:
- 创建 email_verification_token
- 发送验证邮件
5. 返回 LoginResponse (如果不需要验证) 或 UserResponse (如果需要验证)
```
### 4.2 手机号注册(短信验证码)
```
Step 1: POST /api/v1/auth/sms/send
Body: { "phone": "13800001234", "purpose": "register" }
1. 检查 config.registration_sms_enabled == true
2. 限流检查
3. 生成验证码,存入 sms_codes 表
4. 发送短信
Step 2: POST /api/v1/auth/sms/verify
Body: { "phone": "13800001234", "code": "123456", "purpose": "register" }
1. 校验验证码
2. 事务:
a. 查找 user_credentials WHERE auth_type='phone' AND identifier=phone
b. 如果已存在 → 登录(签发 JWT)
c. 如果不存在:
- INSERT users (username='user_13800001234')
- INSERT user_credentials (auth_type='phone', identifier=phone, verified=true)
- 签发 JWT
3. 返回 LoginResponse
```
### 4.3 OAuth 注册
```
Step 1: GET /api/v1/auth/oauth/{provider} → 302 到 Provider
Step 2: GET /api/v1/auth/oauth/{provider}/callback?code=xxx&state=yyy
1. 交换 code → access_token
2. 获取 Provider 用户信息 (provider_user_id, email, display_name, avatar)
3. 查找 user_credentials WHERE auth_type='oauth_{provider}' AND identifier='{provider}:{provider_user_id}'
4. 如果已存在 → 找到 user_id → 签发 JWT
5. 如果不存在:
a. 查找 user_credentials WHERE auth_type='email' AND identifier=email (如果 provider 返回了 email)
- 如果找到 → 自动绑定(增加一条 credential)
b. 否则:
- 事务:
- INSERT users (username=display_name 或 '{provider}_{id[:8]}')
- INSERT user_credentials (auth_type='oauth_{provider}', identifier='github:12345', verified=true)
- INSERT oauth_accounts (token 等信息)
- 如果 provider 返回了 email:
INSERT user_credentials (auth_type='email', identifier=email, credential_data='{}', verified=true)
- 签发 JWT
6. 返回 LoginResponse (或 302 重定向到前端)
```
---
## 5. 登录流程
### 5.1 邮箱密码登录
```
POST /api/v1/auth/login
Body: { "email": "alice@example.com", "password": "Secret123!" }
1. 查找 user_credentials WHERE auth_type='email' AND identifier=email
2. 如果未找到 → Unauthorized
3. 验证 secret (Argon2id)
4. 检查 verified (如果 require_email_verification)
5. 通过 user_id 找到 user
6. 签发 JWT + refresh_token
7. 返回 LoginResponse
```
### 5.2 手机号登录
与 4.2 的 verify 流程相同 — 验证码验证成功即登录。
### 5.3 OAuth 登录
与 4.3 的 callback 流程相同 — 找到已绑定的 credential 即登录。
---
## 6. 凭证绑定与解绑
### 6.1 绑定新凭证(已登录用户)
所有绑定操作的通用模式:
```
POST /api/v1/auth/credentials/bind
Body: { "auth_type": "email", "identifier": "alice@gmail.com", "secret": "NewPass123!" }
Auth: Bearer token (必须已登录)
前置检查:
1. 用户已有凭证数量 >= 1
2. 该 auth_type + identifier 不存在(或属于当前用户)
结果: INSERT user_credentials 一行
```
具体端点:
| 操作 | 端点 | 实现 |
|------|------|------|
| 绑定邮箱+密码 | `POST /auth/credentials/bind-email` | auth_type=email, 需要 email 验证 |
| 绑定手机号 | `POST /auth/phone/bind` | auth_type=phone, 需要 SMS 验证 |
| 绑定 OAuth | `GET /auth/oauth/{provider}` (已登录) | auth_type=oauth_{provider}, OAuth 流程 |
### 6.2 解绑凭证
```
DELETE /api/v1/auth/credentials/{credential_id}
Auth: Bearer token (必须已登录)
前置检查:
1. 该 credential 属于当前用户
2. 该用户剩余 credential 数量 > 1 (防止锁死)
3. 如果是最后一个凭证 → 返回 400 "cannot_remove_last_credential"
结果: DELETE user_credentials 一行
```
### 6.3 设置密码(OAuth/手机号用户首次设密码)
```
POST /api/v1/auth/set-password
Body: { "email": "alice@gmail.com", "password": "NewPass123!" }
Auth: Bearer token (必须已登录)
1. 检查当前用户是否已有 email 凭证
- 如果有 → 400 "password_already_set"
- 如果没有 → 创建新凭证:
INSERT user_credentials (auth_type='email', identifier=email, credential_data=json(password_hash))
2. 返回 200
```
---
## 7. 密码管理
### 7.1 修改密码(已有密码的用户)
```
POST /api/v1/auth/change-password
Body: { "old_password": "...", "new_password": "..." }
Auth: Bearer token
1. 找到当前用户的 email 凭证
2. 验证 old_password (Argon2id)
3. 更新 secret 为新 hash
```
### 7.2 忘记密码(未登录)
```
Step 1: POST /api/v1/auth/forgot-password
Body: { "email": "alice@example.com" }
1. 查找 user_credentials WHERE auth_type='email' AND identifier=email
2. 生成 password_reset_token
3. 发送重置邮件
Step 2: POST /api/v1/auth/reset-password
Body: { "token": "...", "new_password": "..." }
1. 验证 token
2. 更新对应 credential 的 secret
```
### 7.3 短信重置密码(无邮箱用户)
```
Step 1: POST /api/v1/auth/sms/send
Body: { "phone": "13800001234", "purpose": "reset_password" }
Step 2: POST /api/v1/auth/sms/reset-password
Body: { "phone": "13800001234", "code": "123456", "new_password": "..." }
1. 验证 SMS code
2. 找到用户,创建 email 凭证 (需要前端提供 email) 或直接更新 phone 凭证
```
---
## 8. 配置项
| 环境变量 | 类型 | 默认值 | 说明 |
|---------|------|--------|------|
| `REGISTRATION_EMAIL_ENABLED` | bool | `true` | 允许邮箱密码注册 |
| `REGISTRATION_SMS_ENABLED` | bool | `false` | 允许手机号注册 |
| `OAUTH_ENABLED` | bool | `false` | 启用 OAuth |
| `REQUIRE_EMAIL_VERIFICATION` | bool | `false` | 邮箱注册后是否强制验证 |
| `OAUTH_GITHUB_CLIENT_ID` | string | — | GitHub OAuth |
| `OAUTH_GITHUB_CLIENT_SECRET` | string | — | GitHub OAuth |
| `OAUTH_GOOGLE_CLIENT_ID` | string | — | Google OAuth |
| `OAUTH_GOOGLE_CLIENT_SECRET` | string | — | Google OAuth |
| `OAUTH_WECHAT_APP_ID` | string | — | 微信 OAuth |
| `OAUTH_WECHAT_APP_SECRET` | string | — | 微信 OAuth |
| `SMS_CODE_LENGTH` | u32 | `6` | 短信验证码位数 |
| `SMS_CODE_EXPIRES_IN` | u64 | `300` | 验证码过期秒数 |
| `SMS_RATE_LIMIT_SECS` | u64 | `60` | 发送间隔秒数 |
---
## 9. API 端点汇总
### 9.1 公开端点(无需认证)
| Method | Path | 说明 |
|--------|------|------|
| POST | `/auth/register` | 邮箱密码注册 |
| POST | `/auth/login` | 邮箱密码登录 |
| POST | `/auth/sms/send` | 发送短信验证码 |
| POST | `/auth/sms/verify` | 短信验证码登录/注册 |
| POST | `/auth/refresh` | 刷新 access token |
| POST | `/auth/forgot-password` | 忘记密码(发邮件) |
| POST | `/auth/reset-password` | 重置密码 |
| POST | `/auth/verify-email` | 验证邮箱 |
| POST | `/auth/resend-verification` | 重发验证邮件 |
| GET | `/auth/config` | 获取登录配置(哪些方式可用) |
| GET | `/auth/oauth/{provider}` | 发起 OAuth |
| GET | `/auth/oauth/{provider}/callback` | OAuth 回调 |
### 9.2 认证端点(需要 Bearer token)
| Method | Path | 说明 |
|--------|------|------|
| POST | `/auth/logout` | 登出(吊销 refresh token) |
| POST | `/auth/change-password` | 修改密码 |
| POST | `/auth/set-password` | 首次设置密码 |
| POST | `/auth/phone/bind` | 绑定手机号 |
| POST | `/auth/credentials/bind-email` | 绑定邮箱密码 |
| DELETE | `/auth/credentials/{id}` | 解绑指定凭证 |
| GET | `/auth/credentials` | 列出当前用户所有凭证 |
| GET | `/auth/oauth/bindings` | 列出 OAuth 绑定 |
| DELETE | `/auth/oauth/{provider}/unbind` | 解绑 OAuth |
---
## 10. 安全策略
### 10.1 凭证数量保护
```
删除凭证前检查:
SELECT COUNT(*) FROM user_credentials WHERE user_id = ?
如果 count <= 1 → 拒绝删除,返回 400 "cannot_remove_last_credential"
```
### 10.2 OAuth 自动绑定策略
当 OAuth provider 返回的 email 与已有用户匹配时:
| 策略 | 行为 | 适用场景 |
|------|------|---------|
| `auto` (当前) | 自动静默绑定 | 低安全要求 |
| `confirm` (推荐) | 返回待确认状态,前端让用户确认后绑定 | 默认 |
| `disabled` | 不自动绑定,创建新用户 | 高安全要求 |
通过 `OAUTH_EMAIL_BIND_POLICY` 环境变量配置。
### 10.3 密码强度
沿用当前规则:
- 最少 8 字符
- 必须包含字母和数字
- Argon2id 哈希
---
## 11. 旧数据迁移
### 11.1 迁移脚本
```sql
-- Step 1: 创建 user_credentials 表 (见 2.2)
-- Step 2: 迁移邮箱密码凭证
INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'email',
email,
json_quote(password_hash),
email_verified,
created_at,
updated_at
FROM users
WHERE email NOT LIKE '!sms:%' AND email != '';
-- Step 3: 迁移手机号凭证(从 !sms: 哨兵值恢复)
INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
id,
'phone',
substr(email, 6), -- 去掉 '!sms:' 前缀,得到真实手机号
'{}', -- 无密码
1,
created_at,
updated_at
FROM users
WHERE email LIKE '!sms:%';
-- Step 4: 迁移 OAuth 凭证
INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at)
SELECT
lower(hex(randomblob(16))),
oa.user_id,
'oauth_' || oa.provider,
oa.provider || ':' || oa.provider_user_id,
json_object('email', oa.email, 'display_name', oa.display_name),
1,
oa.created_at,
oa.updated_at
FROM oauth_accounts oa;
-- Step 5: 从 users 表移除旧字段
-- (SQLite 不支持 DROP COLUMN,需要重建表)
-- 在新 schema 中直接不包含 email, password_hash, phone, email_verified
```
### 11.2 迁移注意事项
- 先备份 `storage/db/raisfast.db`
- 迁移后 `users` 表不再有 `email`、`password_hash`、`phone`、`email_verified`
- `oauth_accounts` 表保留(存储 OAuth token)
- `sms_codes`、`email_verification_tokens`、`password_reset_tokens` 表不变
---
## 12. 文件变更清单
| 文件 | 变更 |
|------|------|
| `migrations/sqlite/schema.sqlite.sql` | users 表精简 + 新增 user_credentials 表 |
| `migrations/postgres/schema.postgres.sql` | 同上 |
| `migrations/mysql/schema.mysql.sql` | 同上 |
| `src/models/user.rs` | 移除 email/password_hash/phone/email_verified 字段 |
| `src/models/user_credential.rs` | **新增** — UserCredential model + CRUD |
| `src/commands/user.rs` | CreateUserCmd 改为 username + registered_via |
| `src/repositories/sqlx_user.rs` | 简化,移除 find_by_email/find_by_phone/update_password |
| `src/services/auth.rs` | 重写注册/登录,通过 credential 查找 |
| `src/services/sms.rs` | 重写,创建 phone credential 而非哨兵 email |
| `src/services/oauth.rs` | 重写,创建 oauth_* credential,去掉哨兵 |
| `src/services/password_reset.rs` | 适配,操作 credential 而非 user.password_hash |
| `src/services/email_verification.rs` | 适配,更新 credential.verified |
| `src/handlers/auth.rs` | 调整 DTO + 调用新 service 接口 |
| `src/dto/user.rs` | RegisterRequest/LoginRequest 调整 |
| `src/dto/user.rs` | UserResponse 移除 email/phone(改为从 credentials 获取) |
---
## 13. 实施步骤
```
Phase 1: Schema + Model (0.5 天)
1. 更新 schema.sql 三份
2. 创建 user_credential model + CRUD
3. 更新 User model
4. 编译通过
Phase 2: Service 重写 (1 天)
5. 重写 auth.rs 注册/登录
6. 重写 sms.rs
7. 重写 oauth.rs
8. 重写 password_reset.rs
9. 重写 email_verification.rs
Phase 3: Handler + DTO (0.5 天)
10. 更新 auth handler
11. 更新 user DTO
12. 新增 credentials 端点
Phase 4: 测试 (0.5 天)
13. 更新集成测试
14. 新增 credential 绑定/解绑测试
15. 迁移脚本测试
Phase 5: 迁移 (0.5 天)
16. 编写数据迁移脚本
17. 全量回归测试
```
---
## 14. MFA(多因素认证)
### 14.1 设计原则
MFA 是**用户级别**的功能,独立于登录方式。不管用户用邮箱、手机、还是 OAuth 登录,MFA 验证都是同一套。
```
登录流程:
验证主凭证(密码/SMS/OAuth)→ 检查 MFA → 签发 JWT
↓
user_mfa 表有记录?
├── 否 → 直接签发 JWT
└── 是 → 返回 mfa_required,需二次验证
```
MFA 与凭证分离的好处:
- 用户换登录方式不影响 MFA 设置
- 新增 MFA 方式不改 `user_credentials` 表
- 可以同时启用多种 MFA(TOTP 作为主方式,恢复码作为备用)
### 14.2 `user_mfa` 表(新增)
```sql
CREATE TABLE IF NOT EXISTS user_mfa (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
method TEXT NOT NULL, -- 'totp' | 'webauthn' | 'sms' | 'email_otp'
mfa_data TEXT NOT NULL, -- JSON: 方法特定数据(见下方 schema)
verified INTEGER NOT NULL DEFAULT 0, -- 设置流程中是否已确认
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(user_id, method) -- 每种方式每用户一行
);
CREATE INDEX IF NOT EXISTS idx_user_mfa_user ON user_mfa(user_id);
```
### 14.3 `mfa_data` JSON schema
每种 MFA 方法定义自己的数据结构,`mfa_data` 字段永远不需要 ALTER TABLE:
| method | mfa_data | 说明 |
|--------|----------|------|
| `totp` | `{"secret": "JBSWY3DPEHPK3PXP", "algorithm": "SHA1", "digits": 6, "period": 30}` | RFC 6238 TOTP,Microsoft/Google Authenticator |
| `webauthn` | `{"credential_id": "base64...", "public_key": "base64...", "sign_count": 0, "transports": ["usb","ble"]}` | FIDO2 / Passkey,未来最高安全等级 |
| `sms` | `{}` | 复用 `sms_codes` 表,发验证码到绑定手机 |
| `email_otp` | `{}` | 复用 `email_verification_tokens` 表,发验证码到绑定邮箱 |
### 14.4 `user_mfa_recovery_codes` 表(新增)
```sql
CREATE TABLE IF NOT EXISTS user_mfa_recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL, -- bcrypt hash
used_at TEXT, -- NULL = 未使用
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_mfa_recovery_user ON user_mfa_recovery_codes(user_id);
```
### 14.5 MFA 流程
#### 14.5.1 登录时触发 MFA
```json
// Step 1: 正常登录请求(任何登录方式)
POST /api/v1/auth/login
Body: { "email": "alice@example.com", "password": "Secret123!" }
// 如果该用户启用了 MFA,返回:
{
"code": 0,
"message": "mfa_required",
"data": {
"status": "mfa_required",
"mfa_token": "eyJ...short-lived-token...",
"methods": ["totp"],
"hint": "a***@gmail.com"
}
}
// mfa_token 有效期 5 分钟,只能用于完成 MFA 验证,不能当作 access_token
// Step 2: MFA 验证
POST /api/v1/auth/mfa/verify
Body: { "mfa_token": "eyJ...", "method": "totp", "code": "123456" }
// 验证通过后返回正常的 LoginResponse:
{
"code": 0,
"message": "success",
"data": {
"access_token": "eyJ...",
"refresh_token": "...",
"expires_in": 3600,
"user": { ... }
}
}
```
#### 14.5.2 所有登录方式统一触发
| 登录方式 | 主凭证验证后 | MFA 检查 |
|---------|-----------|---------|
| 邮箱密码 | 密码正确 → | `SELECT * FROM user_mfa WHERE user_id = ?` |
| 手机短信 | 验证码正确 → | 同上 |
| OAuth | provider 验证通过 → | 同上 |
| Passkey | 签名验证通过 → | 同上 |
### 14.6 MFA 设置(绑定)
#### 14.6.1 启用 TOTP
```
Step 1: POST /api/v1/auth/mfa/setup
Body: { "method": "totp" }
Auth: Bearer token
→ 生成 Base32 secret
→ 返回:
{
"secret": "JBSWY3DPEHPK3PXP",
"qr_url": "otpauth://totp/raisfast:alice?secret=JBSWY3DPEHPK3PXP&issuer=raisfast",
"backup_codes": ["abc12345", "def67890", ...]
}
Step 2: POST /api/v1/auth/mfa/confirm
Body: { "method": "totp", "code": "123456" }
→ 验证 TOTP code 正确
→ INSERT user_mfa (user_id, method='totp', mfa_data='{"secret":"JBSWY3DPEHPK3PXP",...}', verified=1)
→ INSERT user_mfa_recovery_codes × 10 条
```
#### 14.6.2 启用 WebAuthn / Passkey(未来)
```
Step 1: POST /api/v1/auth/mfa/setup
Body: { "method": "webauthn" }
→ 生成 challenge,返回 WebAuthn registration options
→ 前端调用 navigator.credentials.create()
Step 2: POST /api/v1/auth/mfa/confirm
Body: { "method": "webauthn", "credential": { "id": "...", "publicKey": "...", ... } }
→ 验证签名
→ INSERT user_mfa (user_id, method='webauthn', mfa_data='{...}')
```
#### 14.6.3 启用短信/邮箱 OTP
```
POST /api/v1/auth/mfa/setup
Body: { "method": "sms" }
→ 检查用户是否已绑定手机号(有 phone credential)
→ INSERT user_mfa (user_id, method='sms', mfa_data='{}')
```
### 14.7 MFA 解除
```
DELETE /api/v1/auth/mfa
Body: { "code": "123456" } // 需要当前 MFA 验证码确认
Auth: Bearer token
→ 验证 code 正确后:
DELETE FROM user_mfa WHERE user_id = ?
DELETE FROM user_mfa_recovery_codes WHERE user_id = ?
```
### 14.8 恢复码使用
```
POST /api/v1/auth/mfa/verify
Body: { "mfa_token": "eyJ...", "method": "recovery_code", "code": "abc12345" }
→ 查找 user_mfa_recovery_codes WHERE user_id=? AND code_hash 匹配 AND used_at IS NULL
→ 标记 used_at = now()
→ 签发 JWT
→ 如果剩余未使用恢复码 < 3,提示用户重新生成
```
### 14.9 MFA API 端点
| Method | Path | Auth | 说明 |
|--------|------|------|------|
| POST | `/auth/mfa/verify` | mfa_token | 完成 MFA 二次验证 |
| POST | `/auth/mfa/setup` | Bearer | 发起 MFA 设置(返回 secret/QR/challenge) |
| POST | `/auth/mfa/confirm` | Bearer | 确认 MFA 设置(验证码/签名确认) |
| DELETE | `/auth/mfa` | Bearer | 关闭 MFA(需验证码) |
| GET | `/auth/mfa/status` | Bearer | 查询当前 MFA 状态 |
| POST | `/auth/mfa/recovery-codes/regenerate` | Bearer | 重新生成恢复码 |
### 14.10 安全策略
| 策略 | 说明 |
|------|------|
| mfa_token 有效期 5 分钟 | 一次性使用,验证后作废 |
| TOTP 容差 ±1 个时间窗口(30s×3) | 防止时钟偏移导致失败 |
| 恢复码使用后立即作废 | 每个码只能用一次 |
| 关闭 MFA 需要验证码 | 防止攻击者直接关闭 |
| 恢复码以 hash 存储 | 数据库泄露后无法直接使用 |
| 可配置强制 MFA | `REQUIRE_MFA=true` 时管理员/作者角色必须启用 MFA |
| TOTP secret 加密存储 | `mfa_data.secret` 用 AES-256-GCM 加密,key 从 `MFA_ENCRYPTION_KEY` 读取 |
### 14.11 未来扩展
新增 MFA 方式只需:
1. 定义新的 `method` 字符串(如 `"webauthn"`)
2. 定义对应的 `mfa_data` JSON schema
3. 实现该方法的验证逻辑 service
不需要 ALTER TABLE,不需要改 `user_credentials`,不需要改登录流程。
---
## 15. 实名认证(未来计划,暂不实施)
### 15.1 设计思路
实名认证与登录凭证是**两个独立维度**:
- 凭证 = 你怎么证明你是你(密码、OAuth、MFA)
- 实名 = 你在现实世界是谁(身份证、护照、营业执照)
实名认证是**用户级别的身份属性**,独立于任何登录方式。
### 15.2 `user_identity_verifications` 表(未来创建)
```sql
CREATE TABLE IF NOT EXISTS user_identity_verifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
verification_type TEXT NOT NULL, -- 'id_card' | 'passport' | 'business_license' | 'phone_realname'
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'verified' | 'rejected'
id_number TEXT, -- AES-256-GCM 加密存储的证件号
real_name TEXT, -- AES-256-GCM 加密存储的真实姓名
verification_data TEXT, -- JSON: 第三方认证返回、认证凭证、OCR 结果等
verified_at TEXT,
rejected_reason TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(user_id, verification_type)
);
CREATE INDEX IF NOT EXISTS idx_identity_verifications_user ON user_identity_verifications(user_id);
CREATE INDEX IF NOT EXISTS idx_identity_verifications_status ON user_identity_verifications(status);
```
### 15.3 `verification_type` 及 `verification_data` schema
| verification_type | verification_data | 说明 |
|-------------------|-------------------|------|
| `id_card` | `{"front_image":"...","back_image":"...","ocr_result":{...},"third_party_ref":"..."}` | 中国大陆身份证 |
| `passport` | `{"passport_image":"...","ocr_result":{...}}` | 护照 |
| `business_license` | `{"license_image":"...","company_name":"...","unified_code":"..."}` | 企业营业执照 |
| `phone_realname` | `{"phone":"138...","carrier":"cmcc"}` | 运营商三要素实名 |
| `bank_card` | `{"bank":"ICBC","card_last4":"1234"}` | 银行卡四要素实名 |
### 15.4 实名认证流程(未来实现)
```
Step 1: POST /api/v1/auth/identity/submit
Body: { "type": "id_card", "real_name": "张三", "id_number": "110...", "front_image": "base64..." }
Auth: Bearer token
→ 创建验证记录,status='pending'
→ 调用第三方实名认证 API(阿里云 / 腾讯云)
→ 更新 status='verified' 或 'rejected'
Step 2: GET /api/v1/auth/identity/status
Auth: Bearer token
→ 返回当前实名状态
```
### 15.5 安全要求
| 要求 | 说明 |
|------|------|
| 证件号加密存储 | `id_number` 和 `real_name` 用 AES-256-GCM 加密,key 从 `IDENTITY_ENCRYPTION_KEY` 读取 |
| 查询脱敏 | API 返回时脱敏:`id_number` → `"110***********1234"`,`real_name` → `"张*"` |
| 不可逆 | 不提供明文查询接口,只返回是否已认证 + 脱敏信息 |
| 审计日志 | 所有实名认证操作记录到 audit log |
### 15.6 与权限系统联动(未来)
```
配置: REQUIRE_IDENTITY_VERIFICATION=true
未实名用户:
- 可以登录
- 可以浏览内容
- 不能发布内容(POST /api/v1/posts → 403 "identity_verification_required")
- 不能提现 / 操作敏感功能
已实名用户:
- 解锁全部功能
```