# GitHub OAuth 接入详细步骤
## 1. 创建 GitHub OAuth App
### 1.1 进入 GitHub 开发者设置
1. 登录 GitHub
2. 进入 **Settings** → **Developer settings** → **OAuth Apps**
3. 直接链接:https://github.com/settings/developers
### 1.2 注册新应用
点击 **New OAuth App**,填写以下信息:
| 字段 | 本地开发值 | 生产环境值 |
|------|-----------|-----------|
| Application name | `raisfast-dev` | `你的产品名` |
| Homepage URL | `http://localhost:3000` | `https://yourdomain.com` |
| Authorization callback URL | `http://localhost:9000/api/v1/auth/oauth/github/callback` | `https://api.yourdomain.com/api/v1/auth/oauth/github/callback` |
| Application description | (可选) | (可选) |
> **注意**:Callback URL 必须与后端路由完全匹配,包括协议(http/https)。
点击 **Register application**。
### 1.3 获取凭证
注册完成后,在应用详情页:
1. 记下 **Client ID**(形如 `Ov23lixxxxxxxxxx`)
2. 点击 **Generate a new client secret**
3. 记下 **Client Secret**(只显示一次,务必保存)
> 如果忘记 Secret,可以重新生成,旧的会立即失效。
## 2. 配置后端环境变量
编辑项目根目录 `.env` 文件:
```env
# ── OAuth2 社交登录 ──────────────────────────────────────────
OAUTH_ENABLED=true
OAUTH_REDIRECT_URL=http://localhost:3000/auth/callback
# GitHub OAuth
OAUTH_GITHUB_CLIENT_ID=Ov23liwYwANw8fvQVhI0
OAUTH_GITHUB_CLIENT_SECRET=42ab89916b6731b3ead0dbfadca581aecbb6cb60
```
### 配置项说明
| 环境变量 | 必填 | 默认值 | 说明 |
|---------|------|--------|------|
| `OAUTH_ENABLED` | 是 | `false` | 总开关,必须设为 `true` |
| `OAUTH_REDIRECT_URL` | 否 | `http://localhost:3000/auth/callback` | 登录成功后 302 重定向到前端的地址 |
| `OAUTH_GITHUB_CLIENT_ID` | 是 | - | GitHub OAuth App 的 Client ID |
| `OAUTH_GITHUB_CLIENT_SECRET` | 是 | - | GitHub OAuth App 的 Client Secret |
> **修改 `.env` 后必须重启后端进程**才能生效。
## 3. 运行数据库迁移
OAuth 功能需要 3 张新表:
| 表名 | 用途 |
|------|------|
| `oauth_accounts` | 存储 OAuth 绑定关系 |
| `oauth_states` | 存储 PKCE state(短期,10 分钟过期) |
| `users` (改动) | 新增 `email_verified` 字段 |
迁移文件:`migrations/019_oauth.sql`
首次启动后端时自动执行,也可以手动运行:
```bash
cargo sqlx migrate run --features "db-sqlite"
```
## 4. 启动后端
```bash
SWAGGER_UI_DOWNLOAD_URL=file:///tmp/swagger-ui.zip cargo run --features "db-sqlite"
```
启动成功后,日志中应看到:
```
OAuth provider registered: github
```
如果没有看到这行,说明 `OAUTH_GITHUB_CLIENT_ID` 或 `OAUTH_GITHUB_CLIENT_SECRET` 未正确配置。
## 5. 验证配置
```bash
# 查看已配置的 OAuth Provider
curl http://localhost:9000/api/v1/auth/oauth/providers
# 预期返回:
# {"code":0,"message":"操作成功","data":[{"name":"github","configured":true}]}
```
## 6. 测试完整流程
### 6.1 发起 OAuth 登录
在浏览器中打开:
```
http://localhost:9000/api/v1/auth/oauth/github
```
预期行为:302 重定向到 GitHub 授权页面,URL 形如:
```
https://github.com/login/oauth/authorize
?client_id=Ov23liwYwANw8fvQVhI0
&state=a3f8b2c1d4e5...(64字符随机串)
&code_challenge=E9Melhoa2OwvFrEM...(PKCE challenge)
&code_challenge_method=S256
&scope=user:email
```
### 6.2 在 GitHub 授权
点击 **Authorize** 按钮。
### 6.3 GitHub 回调
GitHub 将用户重定向到:
```
http://localhost:9000/api/v1/auth/oauth/github/callback?code=xxx&state=xxx
```
后端处理流程:
1. 校验 `state`(防 CSRF,一次性使用)
2. 用 `code` + PKCE `code_verifier` 换 access_token
3. 获取 GitHub 用户信息(`/user` + `/user/emails`)
4. 查找/创建本地用户
5. 签发 JWT
### 6.4 重定向到前端
处理完成后,302 重定向到:
```
http://localhost:3000/auth/callback
?access_token=eyJhbGciOiJIUzI1NiIs...
&refresh_token=a3f8b2c1d4e5f6...
&expires_in=900
```
### 6.5 curl 测试(不跟随重定向)
```bash
# 测试发起授权
curl -v http://localhost:9000/api/v1/auth/oauth/github 2>&1 | grep "Location:"
# 应输出类似:
# Location: https://github.com/login/oauth/authorize?client_id=...
```
## 7. 前端对接
### 7.1 登录页添加 GitHub 按钮
```tsx
// web/src/app/login/page.tsx
const API_BASE = "http://localhost:9000/api/v1";
GitHub 登录
```
> 直接用 `` 跳转,不需要 `fetch`。浏览器会跟随 302 重定向到 GitHub。
### 7.2 创建 OAuth 回调页
```tsx
// web/src/app/auth/callback/page.tsx
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function OAuthCallback() {
const router = useRouter();
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");
const error = params.get("error");
if (error) {
console.error("OAuth error:", error);
router.push("/login?error=oauth_failed");
return;
}
if (accessToken) {
localStorage.setItem("access_token", accessToken);
localStorage.setItem("refresh_token", refreshToken || "");
router.push("/");
} else {
router.push("/login?error=no_token");
}
}, [router]);
return 正在登录...
;
}
```
### 7.3 账号设置页 — 绑定/解绑
```tsx
// 查看已绑定 Provider
const { data } = useQuery({
queryKey: ["oauth-bindings"],
queryFn: () =>
fetch("http://localhost:9000/api/v1/auth/oauth/bindings", {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json()),
});
// 解绑
const unbind = async (provider: string) => {
await fetch(`http://localhost:9000/api/v1/auth/oauth/${provider}/unbind`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
};
```
## 8. API 端点完整列表
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | `/api/v1/auth/oauth/{provider}` | 无 | 发起 OAuth 登录,302 到 Provider |
| GET | `/api/v1/auth/oauth/{provider}/callback` | 无 | Provider 回调处理 |
| GET | `/api/v1/auth/oauth/providers` | 无 | 已配置的 Provider 列表 |
| GET | `/api/v1/auth/oauth/bindings` | 需登录 | 当前用户绑定列表 |
| DELETE | `/api/v1/auth/oauth/{provider}/unbind` | 需登录 | 解绑指定 Provider |
## 9. 用户场景说明
### 场景 A:首次 GitHub 登录(自动注册)
```
1. 用户点击 "GitHub 登录"
2. GitHub 授权
3. 后端发现该 GitHub ID 未绑定任何本地用户
4. 自动创建本地用户:
- username = GitHub login 名(冲突时追加前缀/随机后缀)
- email = GitHub 主邮箱
- password = 占位符("!oauth:github:xxx")
- role = "reader"
- avatar = GitHub 头像
- email_verified = 1
5. 创建 oauth_accounts 绑定
6. 签发 JWT,重定向到前端
```
### 场景 B:已有账号,邮箱匹配自动绑定
```
1. 用户已用 email 注册(如 alice@gmail.com)
2. GitHub 账号主邮箱也是 alice@gmail.com
3. 首次用 GitHub 登录
4. 后端发现邮箱匹配已有用户
5. 自动创建 oauth_accounts 绑定
6. 签发 JWT,重定向到前端
```
### 场景 C:再次 GitHub 登录(已有绑定)
```
1. 用户再次点击 "GitHub 登录"
2. GitHub 授权
3. 后端在 oauth_accounts 找到已有绑定
4. 更新 GitHub token/profile 信息
5. 签发 JWT,重定向到前端
```
### 场景 D:已登录用户绑定 GitHub
```
1. 用户在账号设置页点击 "绑定 GitHub"
2. 前端带 token 跳转 /api/v1/auth/oauth/github
(后端从 JWT 提取 user_id,存入 oauth_states)
3. GitHub 授权
4. 回调时后端发现 state.user_id 存在
5. 创建 oauth_accounts 绑定到该用户
6. 签发 JWT,重定向到前端
```
### 场景 E:解绑
```
1. 用户在账号设置页点击 "解绑 GitHub"
2. DELETE /api/v1/auth/oauth/github/unbind
3. 后端检查安全性:
- 如果用户没有密码(纯 OAuth 用户)且只剩这一个绑定 → 拒绝
- 否则 → 删除绑定
```
## 10. 安全机制
### PKCE(Proof Key for Code Exchange)
每次授权请求:
```
1. 生成 code_verifier(43 字符随机串)
2. code_challenge = BASE64URL(SHA256(code_verifier))
3. 授权 URL 携带 code_challenge + method=S256
4. 回调时发送 code_verifier
5. GitHub 用 SHA256 验证匹配
```
防止 authorization code 被截获后滥用。
### State 参数(CSRF 防护)
```
1. 生成 state(64 字符随机 hex)
2. 存入 oauth_states 表(10 分钟过期)
3. 授权 URL 携带 state
4. 回调时校验 state 匹配
5. 用后即删(一次性)
```
### 解绑安全
无密码的纯 OAuth 用户仅剩一个绑定时,拒绝解绑,防止用户无法登录。
## 11. 生产环境部署
### 必须修改的配置
```env
# .env.production
OAUTH_ENABLED=true
OAUTH_REDIRECT_URL=https://yourdomain.com/auth/callback
# 强密钥
JWT_SECRET=至少32字符的随机字符串
# CORS
CORS_ORIGINS=https://yourdomain.com
```
### GitHub OAuth App 配置更新
在 GitHub OAuth App 设置中更新:
| 字段 | 值 |
|------|-----|
| Homepage URL | `https://yourdomain.com` |
| Authorization callback URL | `https://api.yourdomain.com/api/v1/auth/oauth/github/callback` |
> 如果前后端同域(通过 nginx 反向代理),callback URL 可以是:
> `https://yourdomain.com/api/v1/auth/oauth/github/callback`
### Nginx 配置示例
```nginx
server {
listen 443 ssl;
server_name yourdomain.com;
# 前端
location / {
proxy_pass http://127.0.0.1:3000;
}
# 后端 API
location /api/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## 12. 故障排查
### "OAuth is not enabled"
```
检查:
1. .env 中 OAUTH_ENABLED=true(无 # 前缀)
2. 重启了后端进程
3. 进程启动日志中有 "OAuth provider registered: github"
```
### "OAuth provider 'github' is not configured"
```
检查:
1. OAUTH_GITHUB_CLIENT_ID 和 OAUTH_GITHUB_CLIENT_SECRET 都已设置
2. 值不为空
3. 重启了后端进程
```
### GitHub 回调报 400 "invalid or expired OAuth state"
```
可能原因:
1. state 超过 10 分钟过期(用户在 GitHub 页面停留太久)
2. state 被重复使用(浏览器刷新了回调 URL)
3. oauth_states 表未创建(迁移未执行)
解决:重新发起授权(重新访问 /api/v1/auth/oauth/github)
```
### GitHub 回调报 "GitHub token exchange failed"
```
可能原因:
1. Client Secret 不正确
2. Authorization code 已过期(10 分钟有效期)
3. GitHub OAuth App 的 callback URL 不匹配
解决:
1. 在 GitHub App 设置页重新生成 Secret
2. 确认 callback URL 完全匹配
```
### 邮箱未自动获取
```
GitHub API 只返回 public 邮箱。私有邮箱需要:
1. 授权时 scope=user:email(已包含)
2. 额外调用 GET /user/emails API(已自动处理)
如果仍然没有邮箱,检查:
1. GitHub 账号是否设置了主邮箱
2. 邮箱是否已验证
```
### 重定向到前端后 token 无效
```
检查:
1. OAUTH_REDIRECT_URL 是否正确
2. JWT_SECRET 是否在重启后变了
3. 前端是否正确提取 URL 参数
```