# 统一用户认证系统设计 > 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, pub username: String, pub display_name: Option, pub avatar: Option, pub bio: Option, pub website: Option, pub slug: Option, pub locale: Option, 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") - 不能提现 / 操作敏感功能 已实名用户: - 解锁全部功能 ```