# OAuth2 社交登录设计方案 ## 概述 支持 GitHub、Google、微信等第三方 OAuth2 登录,采用标准 Authorization Code + PKCE 流程。 首次 OAuth 登录自动创建本地用户,已有账号可绑定/解绑 OAuth Provider。 ## 流程 ``` 用户点击 "GitHub 登录" → GET /api/v1/auth/oauth/{provider} → 302 重定向到 OAuth Provider 授权页 → 用户在 Provider 授权 → Provider 回调 → GET /api/v1/auth/oauth/{provider}/callback?code=xxx&state=xxx → 后端用 code + code_verifier 换 access_token → 用 access_token 调用 Provider 用户信息 API → 查找或创建本地用户 → 签发 JWT access_token + refresh_token → 302 重定向前端回调页,URL 拼接 token 参数 → 前端从 URL 提取 token,存入 localStorage / cookie ``` ## API 端点 ### 1. 发起 OAuth 登录 ``` GET /api/v1/auth/oauth/{provider} ``` - `{provider}`: `github` | `google` | `wechat` - 生成 `state`(CSRF token)和 `code_verifier`(PKCE),存入 DB 或 Redis - 302 重定向到 Provider 授权 URL - 可选查询参数 `?bind=1`,表示已登录用户绑定第三方账号 ### 2. OAuth 回调 ``` GET /api/v1/auth/oauth/{provider}/callback?code=xxx&state=xxx ``` - 校验 `state` 防止 CSRF - 用 `code` + `code_verifier` 换 Provider 的 access_token - 获取 Provider 用户信息(ID、邮箱、昵称、头像) - 查找 `oauth_accounts` 表: - 已绑定 → 直接签发 token - 未绑定 + `bind=1` → 绑定到当前登录用户 - 未绑定 + 邮箱匹配已有用户 → 自动绑定(可选,需评估安全风险) - 未绑定 + 新用户 → 自动注册 - 302 重定向到 `OAUTH_REDIRECT_URL?access_token=...&refresh_token=...&expires_in=...` ### 3. 绑定 OAuth 账号(已登录用户) ``` POST /api/v1/auth/oauth/bind Authorization: Bearer { "provider": "github" } ``` - 返回绑定用的授权 URL - 前端跳转该 URL,回调时自动绑定 ### 4. 解绑 OAuth 账号 ``` DELETE /api/v1/auth/oauth/{provider} Authorization: Bearer ``` - 解绑指定 Provider - 若用户无密码且仅剩一个绑定,拒绝解绑(防止无法登录) ### 5. 查询已绑定的 Provider 列表 ``` GET /api/v1/auth/oauth/providers Authorization: Bearer ``` - 返回当前用户已绑定的 Provider 列表 - 前端用于展示绑定状态和解绑操作 ## 数据库 ### 新表 `oauth_accounts` ```sql CREATE TABLE oauth_accounts ( id TEXT PRIMARY KEY, -- UUID v7 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, -- 'github' | 'google' | 'wechat' provider_user_id TEXT NOT NULL, -- Provider 侧的用户 ID email TEXT, -- Provider 返回的邮箱 display_name TEXT, -- Provider 返回的昵称 avatar_url TEXT, -- Provider 返回的头像 URL access_token TEXT, -- Provider 的 access_token(可选,加密存储) refresh_token TEXT, -- Provider 的 refresh_token(可选,加密存储) token_expires_at TEXT, -- Provider token 过期时间 profile TEXT, -- Provider 返回的原始 profile JSON created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(provider, provider_user_id) ); CREATE INDEX idx_oauth_accounts_user ON oauth_accounts(user_id); CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_user_id); ``` ### 新表 `oauth_states`(短期 PKCE state 存储) ```sql CREATE TABLE oauth_states ( id TEXT PRIMARY KEY, -- 即 state 值本身(随机字符串) provider TEXT NOT NULL, code_verifier TEXT NOT NULL, -- PKCE code_verifier user_id TEXT, -- 绑定场景下传入已登录用户 ID redirect_url TEXT, -- 可选自定义回调地址 created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL -- 默认 10 分钟后过期 ); -- 定期清理过期记录(可由 Worker cron 处理) CREATE INDEX idx_oauth_states_expires ON oauth_states(expires_at); ``` ### `users` 表改动 ```sql -- password_hash 改为可空(纯 OAuth 用户无密码) -- 新增迁移: ALTER TABLE users RENAME COLUMN password_hash TO password_hash_old; ALTER TABLE users ADD COLUMN password_hash TEXT; UPDATE users SET password_hash = password_hash_old; ALTER TABLE users DROP COLUMN password_hash_old; -- 新增邮箱验证标记(OAuth 登录的邮箱默认已验证) ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0; ``` > 注意:实际迁移 SQL 需根据 SQLite 的 ALTER TABLE 限制分步执行。 ## Provider 配置 ### 环境变量 ```env # 通用 OAUTH_ENABLED=true OAUTH_REDIRECT_URL=http://localhost:3000/auth/callback # GitHub OAUTH_GITHUB_CLIENT_ID= OAUTH_GITHUB_CLIENT_SECRET= # Google OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_SECRET= # 微信(注意:微信使用独立协议,非标准 OAuth2) OAUTH_WECHAT_APP_ID= OAUTH_WECHAT_APP_SECRET= ``` ### Provider 定义 | Provider | Authorization URL | Token URL | User Info API | Scope | |----------|------------------|-----------|---------------|-------| | GitHub | `https://github.com/login/oauth/authorize` | `https://github.com/login/oauth/access_token` | `GET https://api.github.com/user` + `GET /user/emails` | `user:email` | | Google | `https://accounts.google.com/o/oauth2/v2/auth` | `https://oauth2.googleapis.com/token` | `GET https://www.googleapis.com/oauth2/v2/userinfo` | `openid email profile` | | 微信 | `https://open.weixin.qq.com/connect/qrconnect` | `https://api.weixin.qq.com/sns/oauth2/access_token` | `GET https://api.weixin.qq.com/sns/userinfo` | `snsapi_login` | ### AppConfig 新增字段 ```rust pub struct OAuthConfig { pub enabled: bool, pub redirect_url: String, pub github: Option, pub google: Option, pub wechat: Option, } pub struct GitHubConfig { pub client_id: String, pub client_secret: String, } pub struct GoogleConfig { pub client_id: String, pub client_secret: String, } pub struct WechatConfig { pub app_id: String, pub app_secret: String, } ``` ### Provider 注册表 ```rust pub struct OAuthProviderRegistry { providers: HashMap>, } pub trait OAuthProvider: Send + Sync { /// 返回 Provider 标识(如 "github") fn name(&self) -> &str; /// 构建授权 URL(包含 state、code_challenge、scope) fn authorize_url(&self, state: &str, code_challenge: &str) -> String; /// 用 code + code_verifier 换 access_token async fn exchange_code(&self, code: &str, code_verifier: &str) -> Result; /// 用 access_token 获取用户信息 async fn fetch_user_info(&self, access_token: &str) -> Result; } pub struct OAuthUserInfo { pub provider_user_id: String, pub email: Option, pub display_name: Option, pub avatar_url: Option, pub raw_profile: serde_json::Value, } ``` ## 自动注册策略 OAuth 首次登录且未绑定已有账号时,自动创建本地用户: 1. **username 生成**: - 优先使用 Provider 返回的 `login`(GitHub)或 `name` - 若已存在,追加 Provider 前缀:`github_octocat` - 若仍冲突,追加随机后缀:`github_octocat_3f8a` 2. **email**: - 使用 Provider 返回的邮箱 - GitHub 需额外调用 `/user/emails` API 获取主邮箱 - Google 直接返回 - 标记 `email_verified = true` 3. **password**: - `password_hash = NULL`(纯 OAuth 用户无密码) - 用户可后续设置密码(成为"混合登录"用户) 4. **role**: - 默认 `reader`,与普通注册一致 5. **avatar**: - 使用 Provider 返回的头像 URL ## 安全设计 ### PKCE(Proof Key for Code Exchange) - 每次授权请求生成随机 `code_verifier`(43-128 字符) - `code_challenge = BASE64URL(SHA256(code_verifier))` - `code_challenge_method = S256` - 回调时发送 `code_verifier` 完成 PKCE 验证 - 防止 authorization code 被截获后滥用 ### State 防护 - 每次请求生成随机 `state`,存入 `oauth_states` 表 - 回调时严格校验 `state` 参数 - `state` 10 分钟后自动过期 - 用后即删(一次性) ### Token 存储 - Provider 返回的 access_token / refresh_token 加密存储(AES-256-GCM) - 加密密钥通过环境变量 `OAUTH_TOKEN_ENCRYPTION_KEY` 配置 - 若不需要 Provider 级别 API 代理功能,可选择不存储 Provider token ### 其他 - OAuth 登录同样受 rate_limit 保护 - 审计日志记录所有 OAuth 绑定/解绑操作 - 绑定操作需要二次确认(已登录 + 明确请求) ## 代码文件规划 | 文件 | 职责 | |------|------| | `src/config/oauth.rs` | `OAuthConfig`、Provider 子配置、环境变量加载 | | `src/handlers/oauth.rs` | 路由处理器:redirect、callback、bind、unbind、list providers | | `src/services/oauth.rs` | 业务逻辑:code exchange、find-or-create、绑定/解绑、JWT 签发 | | `src/models/oauth.rs` | `oauth_accounts` + `oauth_states` 表 CRUD | | `src/oauth/mod.rs` | `OAuthProviderRegistry`、trait 定义 | | `src/oauth/github.rs` | GitHub Provider 实现 | | `src/oauth/google.rs` | Google Provider 实现 | | `src/oauth/wechat.rs` | 微信 Provider 实现(注意微信的非标准协议差异) | | `migrations/019_oauth_accounts.sql` | `oauth_accounts` 建表 | | `migrations/020_oauth_states.sql` | `oauth_states` 建表 | | `migrations/021_users_oauth_compat.sql` | `users` 表 password_hash 可空 + email_verified 字段 | ## 实施优先级 ### Phase 1:GitHub OAuth(推荐先做) - 最简单,API 文档清晰 - 开发者友好,适合内部系统 - 只需 `reqwest`(已有依赖)做 HTTP 调用 - 无额外 crate 依赖 ### Phase 2:Google OAuth - 企业用户需求 - 使用 OpenID Connect(OAuth2 上层协议) - 需处理 ID Token(JWT 格式)验证 ### Phase 3:微信登录 - 国内用户刚需 - 微信协议非标准 OAuth2(无 PKCE、自定义错误码、unionid 机制) - 需单独适配 ### Phase 4:账号管理增强 - 邮箱验证流程(非 OAuth 注册用户) - 密码重置(forgot password) - 多 OAuth 账号合并 - Provider token 刷新机制 ## 前端对接 ### 登录按钮 前端在登录页渲染 OAuth 按钮: ```tsx GitHub 登录 ``` ### 回调处理 ```tsx // /auth/callback 页面 useEffect(() => { const params = new URLSearchParams(window.location.search); const accessToken = params.get('access_token'); const refreshToken = params.get('refresh_token'); const expiresIn = params.get('expires_in'); if (accessToken) { localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); navigate('/'); } }, []); ``` ### 账号设置页 - 显示已绑定的 Provider 列表 - 提供绑定/解绑按钮 - 纯 OAuth 用户引导设置密码 ## 依赖 无需新增 crate,使用现有依赖: - `reqwest` — HTTP 调用(code exchange、user info) - `serde_json` — JSON 解析 - `jsonwebtoken` — JWT 签发(复用现有) - `sqlx` — 数据库操作 - `sha2` — PKCE code_challenge 生成(检查是否已引入) - `base64` — BASE64URL 编码(检查是否已引入) > 若 `sha2` 和 `base64` 尚未引入,需在 `Cargo.toml` 添加。