mirror of
https://github.com/RaisFast/raisfast.git
synced 2026-09-23 16:02:25 +00:00
30 KiB
30 KiB
统一用户认证系统设计
raisfast 通用后端平台 — 用户注册/登录/绑定/解绑重构方案。 替代当前"email NOT NULL + 哨兵值"的打补丁方案。
1. 设计原则
| 原则 | 说明 |
|---|---|
| 账户与凭证分离 | users 表只存 profile,登录凭证独立存 user_credentials |
| 无哨兵值 | 不用 !sms:、!oauth: 之类的假数据填充 NOT NULL 列 |
| 可扩展 | 新增登录方式只需加一个 auth_type 枚举值,不改表结构 |
| 多凭证共存 | 一个用户可以同时绑定邮箱密码、手机号、多个 OAuth provider |
| 安全解绑 | 至少保留一个有效凭证,防止用户锁死自己 |
2. 表结构变更
2.1 users 表(精简 — 只保留 profile)
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 表(新增 — 所有登录凭证)
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 为一对一关系:
-- 保留现有 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
// 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 变更
// 之前
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 迁移脚本
-- 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 表(新增)
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 表(新增)
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
// 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 方式只需:
- 定义新的
method字符串(如"webauthn") - 定义对应的
mfa_dataJSON schema - 实现该方法的验证逻辑 service
不需要 ALTER TABLE,不需要改 user_credentials,不需要改登录流程。
15. 实名认证(未来计划,暂不实施)
15.1 设计思路
实名认证与登录凭证是两个独立维度:
- 凭证 = 你怎么证明你是你(密码、OAuth、MFA)
- 实名 = 你在现实世界是谁(身份证、护照、营业执照)
实名认证是用户级别的身份属性,独立于任何登录方式。
15.2 user_identity_verifications 表(未来创建)
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")
- 不能提现 / 操作敏感功能
已实名用户:
- 解锁全部功能