-
Notifications
You must be signed in to change notification settings - Fork 0
Authentication System
本文档详细介绍 StudyWithMiku 的用户认证系统,包括 WebAuthn/FIDO2 无密码登录、OAuth 多平台集成、跨账号合并、Token 管理策略和安全措施。
认证系统采用 无密码 设计,支持两种登录方式:
- WebAuthn/FIDO2 — 使用设备生物识别(Touch ID/Face ID/Windows Hello)或安全密钥
- OAuth 2.0 — 通过 GitHub、Google、Microsoft、LINUX DO 第三方账号登录
flowchart TB
subgraph Client["浏览器"]
LoginPanel["AccountLoginPanel"]
AuthComposable["useAuth()"]
AuthService["auth.js"]
AuthStorage["authStorage"]
WebAuthnHelper["webauthnHelper"]
end
subgraph Server["Cloudflare Workers"]
AuthRoutes["Auth Routes"]
OAuthRoutes["OAuth Routes"]
JWTService["JWT Service"]
WebAuthnService["WebAuthn Service"]
AuthChallengeDO["AuthChallenge DO"]
D1["D1 Database"]
end
subgraph External["外部服务"]
GitHub["GitHub"]
Google["Google"]
Microsoft["Microsoft"]
LinuxDo["LINUX DO"]
end
LoginPanel --> AuthComposable
AuthComposable --> AuthService
AuthComposable --> AuthStorage
AuthService --> WebAuthnHelper
AuthService -->|"HTTPS"| AuthRoutes
AuthService -->|"HTTPS"| OAuthRoutes
AuthRoutes --> JWTService
AuthRoutes --> WebAuthnService
AuthRoutes --> AuthChallengeDO
AuthRoutes --> D1
OAuthRoutes -->|"OAuth 2.0"| External
用户首次注册时通过 WebAuthn 创建凭证,绑定到设备的生物识别或安全密钥。
sequenceDiagram
participant U as 用户
participant B as 浏览器
participant S as 服务端
participant DO as AuthChallenge DO
U->>B: 输入用户名,点击注册
B->>S: POST /auth/register/options {username}
S->>S: 验证用户名格式和唯一性
S->>DO: 存储挑战 (5min TTL)
S-->>B: {challengeId, options}
B->>B: parseRegisterOptions(options)
B->>U: 系统弹出生物识别验证
U->>B: 验证通过
B->>B: serializeCredential(credential)
B->>S: POST /auth/register/verify {challengeId, response}
S->>DO: 获取并删除挑战
S->>S: verifyRegistration()
S->>S: 创建用户 + 保存凭证
S->>S: generateTokenPair()
S-->>B: {user, tokens} + Set-Cookie: swm_refresh_token
B->>B: 存储认证状态
sequenceDiagram
participant U as 用户
participant B as 浏览器
participant S as 服务端
participant DO as AuthChallenge DO
U->>B: 输入用户名,点击登录
B->>S: POST /auth/login/options {username}
S->>DO: 存储挑战 (5min TTL)
S-->>B: {challengeId, options}
B->>B: parseLoginOptions(options)
B->>U: 系统弹出生物识别验证
U->>B: 验证通过
B->>B: serializeCredential(credential)
B->>S: POST /auth/login/verify {challengeId, response}
S->>DO: 获取并删除挑战
S->>S: verifyAuthentication()
S->>S: 更新凭证计数器(克隆检测)
S->>S: generateTokenPair()
S-->>B: {user, tokens, warning?}
已登录用户可以添加新设备或移除已有设备:
-
添加设备:
POST /auth/devices/add/options→POST /auth/devices/add/verify -
删除设备:
DELETE /auth/devices/:id(不允许删除最后一个认证方式) -
设备名称自动生成: 根据
transports和deviceType推断设备类型(如 "内置认证器"、"安全密钥"、"混合认证器")
| 提供商 | Scope | 获取信息 |
|---|---|---|
| GitHub | read:user user:email |
用户名、头像、邮箱 |
openid email profile |
显示名、头像、邮箱 | |
| Microsoft | openid email profile User.Read |
显示名、邮箱 |
| LINUX DO | user |
用户名、头像 |
可用的 OAuth 提供商取决于服务端是否配置了对应的 CLIENT_ID 和 CLIENT_SECRET。前端通过 GET /auth/config 查询当前可用的认证方式。
sequenceDiagram
participant U as 用户
participant B as 浏览器
participant S as 服务端
participant P as OAuth Provider
U->>B: 点击 OAuth 按钮
B->>S: GET /oauth/:provider
S->>S: 生成 state token
S-->>B: 302 重定向到 Provider
B->>P: 授权页面
U->>P: 确认授权
P-->>B: 302 重定向到 callback
B->>S: GET /oauth/:provider/callback?code=...&state=...
S->>S: 验证 state token
S->>P: 交换 code 获取 access_token
S->>P: 获取用户信息
S->>S: 查找或创建用户
S->>S: generateTokenPair()
S-->>B: 302 重定向 #access_token=...&user=...
B->>B: 解析 URL fragment
B->>B: 存储认证状态
OAuth 回调通过 URL fragment(#)传递 token,避免 token 出现在服务端日志中。前端解析后立即清除 URL hash。
已登录用户可以关联额外的 OAuth 账号:
-
POST /oauth/link/:provider— 获取授权 URL(携带当前用户 ID 的 state) - 重定向到提供商授权
- 回调处理时将 OAuth 账号关联到当前用户
如果关联的 OAuth 账号已经存在于另一个用户上,会触发 账号合并流程。
当用户的 WebAuthn 凭证或 OAuth 账号已关联到另一个账号时,系统提供合并机制。
flowchart TB
Detect["检测到凭证冲突"]
GenToken["生成 mergeToken (10min TTL)"]
Store["存储在 AuthChallenge DO"]
Dialog["弹出合并对话框"]
Choose["用户选择数据合并策略"]
Transfer["转移凭证/OAuth 到目标用户"]
MergeData["按策略合并数据"]
Cleanup["清理源用户(如无剩余认证方式)"]
Detect --> GenToken --> Store --> Dialog --> Choose --> Transfer --> MergeData --> Cleanup
用户可以为每种数据类型独立选择合并策略:
| 选项 | 说明 |
|---|---|
target |
保留目标账号(当前登录)的数据 |
source |
使用源账号的数据 |
merge |
智能合并两个账号的数据 |
各数据类型的合并逻辑:
| 数据类型 | 合并策略 |
|---|---|
focus_records |
按 ID 去重 + 按 updatedAt 做 LWW |
playlists |
目标优先,按时间戳合并 |
focus_settings / user_settings
|
LWW by version + deep merge |
| Token 类型 | 存储位置 | 有效期 | 用途 |
|---|---|---|---|
| Access Token | 内存 (JavaScript 变量) | 15 分钟 | API 请求授权 |
| Refresh Token |
HttpOnly Cookie (swm_refresh_token) |
7 天 | 刷新 Access Token |
这种分层设计确保:
- Access Token 不写入任何持久化存储,XSS 攻击无法窃取
- Refresh Token 通过 HttpOnly + SameSite=Strict Cookie 传输,JavaScript 无法读取
- 即使 Access Token 泄露,15 分钟后自动失效
sequenceDiagram
participant B as 浏览器
participant S as 服务端
Note over B: Access Token 即将过期 (< 60s)
B->>S: POST /auth/refresh (Cookie 自动携带)
S->>S: 验证 Refresh Token
S->>S: 检查黑名单
S->>S: 吊销旧 Refresh Token
S->>S: 生成新 Token Pair
S-->>B: {accessToken, expiresIn} + Set-Cookie (新 Refresh Token)
Note over B: 更新内存中的 Access Token
前端通过定时器(AUTH_CONFIG.TOKEN_CHECK_INTERVAL = 30s)检查 Token 是否即将过期,在过期前 60 秒自动刷新。
注销时将 Access Token 和 Refresh Token 的 jti 加入 token_blacklist 表。每次认证请求都会检查黑名单。过期的黑名单条目通过概率性清理(10% 概率)自动移除。
通过 PATCH /auth/me 更新以下字段:
-
displayName— 显示名称 -
email— 邮箱(用于 Gravatar 头像) -
qqNumber— QQ 号(用于 QQ 头像) -
avatarUrl— 自定义头像 URL
用户可以从多个来源选择头像:
| 来源 | 说明 |
|---|---|
| 自动 | 按优先级自动选择最佳头像 |
| OAuth | 使用 OAuth 提供商的头像 |
| Gravatar | 基于邮箱 SHA-256 哈希 |
| Libravatar | 基于邮箱的开源头像服务 |
| 基于 QQ 号的头像 | |
| 自定义 | 用户提供的 URL |
UserAvatar.vue 组件按优先级尝试加载,某个来源加载失败会自动回退到下一个。
GET /auth/methods 返回统一的认证方法列表,包含 WebAuthn 和 OAuth 两类:
// WebAuthn 设备
{ id, type: 'webauthn', deviceName, transports, backedUp, lastUsedAt }
// OAuth 账号
{ id, type: 'oauth', provider, displayName, avatarUrl, email, linkedAt }删除任何认证方法时会检查是否为最后一个,防止用户锁定自己的账号。
认证相关的 4 张表:
erDiagram
users ||--o{ credentials : "has"
users ||--o{ oauth_accounts : "has"
users ||--o{ token_blacklist : "revokes"
users {
text id PK
text username UK
text displayName
text avatarUrl
text email
text qqNumber
}
credentials {
text id PK
text userId FK
blob publicKey
integer counter
text transports
text deviceType
text deviceName
integer backedUp
integer lastUsedAt
}
oauth_accounts {
text id PK
text userId FK
text provider
text providerId
text displayName
text avatarUrl
text email
integer linkedAt
}
token_blacklist {
text jti PK
integer expiresAt
}
完整的 Schema 定义参见 Backend-Architecture#D1 数据库。
认证相关端点限制为 10 次/分钟(按 IP),通过 RateLimiter Durable Object 实现分布式限制。
WebAuthn 认证时检查凭证的 counter 值。如果新的 counter 没有大于存储的 counter,说明凭证可能被克隆。此时登录仍允许,但响应会携带 warning 字段。
- OAuth state token 绑定到请求会话,回调时验证 state 一致性
- Refresh Token 使用
SameSite=StrictCookie,阻止跨站请求 - 所有认证写操作使用
POST方法
登录时无论用户名是否存在,都返回相同的错误信息,防止攻击者探测有效用户名。
WebAuthn 挑战在验证后立即从 AuthChallenge DO 删除,防止重放攻击。mergeToken 同样在使用后立即销毁。
| 名称 | 文件 | 说明 |
|---|---|---|
useAuth() |
src/composables/useAuth.js |
认证状态管理(单例),提供登录/注册/OAuth/设备管理等方法 |
| 名称 | 文件 | 说明 |
|---|---|---|
| auth service | src/services/auth.js |
封装所有认证 API 调用 |
| authStorage | src/utils/authStorage.js |
Token/用户信息存储管理 |
| webauthnHelper | src/utils/webauthnHelper.js |
WebAuthn 浏览器 API 封装 |
| oauthProviders | src/config/oauthProviders.js |
OAuth 提供商 UI 元数据 |
| 组件 | 文件 | 说明 |
|---|---|---|
| AccountLoginPanel | src/components/settings/tabs/account/AccountLoginPanel.vue |
登录/注册面板 |
| AccountProfilePanel | src/components/settings/tabs/account/AccountProfilePanel.vue |
用户资料编辑 |
| AccountDeviceList | src/components/settings/tabs/account/AccountDeviceList.vue |
认证方法管理 |
| AccountSyncPanel | src/components/settings/tabs/account/AccountSyncPanel.vue |
同步状态面板 |
| OAuthButton | src/components/settings/tabs/account/OAuthButton.vue |
OAuth 登录按钮 |
| AccountDeleteConfirm | src/components/settings/tabs/account/AccountDeleteConfirm.vue |
删除设备确认框 |
| UserAvatar | src/components/common/UserAvatar.vue |
多源头像组件(优先级回退) |