为博客搭建 GitHub 登录系统,打通 OAuth 完整流程
背景:为什么需要登录系统?
我博客的登录现状
我的博客前后端分离,最初博客只是一个静态展示站,没有登录、没有评论,就是一篇文章列表。后来随着内容变多,开始想做更多事情,而很多事情的第一步都是——让用户有一个身份。
目前博客支持两种登录方式:
- GitHub OAuth 登录:点一下按钮,跳转 GitHub 授权,回来就登录好了,适合有 GitHub 账号的读者
- 账号密码登录:注册账号、设置密码,适合不想绑 GitHub 的用户
两种方式并存,但登录入口统一——无论哪种方式进来,拿到的都是同一套 JWT,走的同一张用户表。
有了登录系统能做什么
登录是功能的基础,有了它之后:
评论 — 最重要的功能。有登录才有身份,有身份才知道评论是谁发的。GitHub 登录用户可以用自己的身份评论(显示本人),密码用户由系统代发(显示昵称)。这是搭建登录系统最直接的动力。
点赞与互动 — 登录后可以记录用户对文章的点赞状态,每个人只能点一次,防止重复刷。
用户偏好 — 深色模式、字体大小、文章列表布局等设置可以存到服务端,换设备也保持统一。
后续扩展 — 收藏文章、阅读历史、关注作者、消息通知……这些功能都建立在登录之上,系统搭好了后面随时可以加。
为什么不用第三方登录服务?
市面上有不少现成的身份认证服务(Auth0、Clerk、Supabase Auth 等)。后端本身就在 Cloudflare 上,整套基础设施已经就绪,加一个登录功能不值得再引入外部依赖。GitHub OAuth 的标准接口写起来不复杂,自己掌控也更灵活——数据表自己设计、Token 自己签发、逻辑自己控制。
整体流程(一句话看懂)
用户点"GitHub 登录"后发生的事:
点击 GitHub 登录按钮
→ 前端获取 OAuth 地址 → 跳转 GitHub 授权页
→ 用户确认 → GitHub 回调后端
→ 后端用 code 换 access_token + 获取用户信息
→ 查找或创建本地用户 → 签发 JWT
→ 重定向回前端 → 存 token → 登录完成
整个流程的核心就是 OAuth 2.0 授权码模式——用户授权后,GitHub 给一个临时 code,后端用它换 access_token,再用 token 查用户信息。
整体架构
┌──────────────┐ (1) 跳转 GitHub OAuth ┌─────────────────┐
│ │ ────────────────────────────→ │ │
│ 前端 Next.js │ (2) 回调 redirect_uri │ GitHub OAuth │
│ │ ←──────────────────────────── │ │
│ GitHubButton │ └─────────────────┘
│ Callback页 │ │
│ useAuthStore │ (3) API 请求带 JWT │
│ │ ────────────────────────────→ ┌─────────────────┐
└──────────────┘ │ Cloudflare │
│ 后端服务 │
│ │
│ /auth/login │
│ /auth/github │
│ /auth/callback │
│ /auth/me │
│ │
│ D1 数据库 │
│ user 表 │
└─────────────────┘
准备工作(与评论系统共享同一个 GitHub App)
1. 注册 GitHub App
注意:必须注册 GitHub App,不是 OAuth App。GitHub App 有私钥可以独立调 API,OAuth App 只能做登录。
在 GitHub Settings → Developer settings → GitHub Apps → 新建 App,配置如下:
| 配置项 | 值 |
|---|---|
| Homepage URL | https://你的博客地址 |
| Callback URL | https://你的后端域名/api/auth/github/callback |
| Permissions → Discussions | Read and write(评论需要) |
| Expire user authorization tokens | 建议关闭,否则 access_token 8 小时过期 |
注册后拿到:
- App ID
- Client ID
- Client Secret(手动生成)

2. 环境变量配置
GITHUB_CLIENT_ID=Iv23li...
GITHUB_CLIENT_SECRET=*****
GITHUB_REDIRECT_URI=https://你的后端域名/api/auth/github/callback
FRONTEND_URL=https://你的博客地址
JWT_SECRET=你的jwt密钥
OAuth 2.0 的核心概念(理解原理的关键)
很多读者可能只写过"调接口登录",但没搞清楚背后的流程。这里用最简的方式解释:
OAuth 授权码模式有四个角色:
| 角色 | 在我的系统里对应 |
|---|---|
| 资源所有者(Resource Owner) | 你(GitHub 用户) |
| 客户端(Client) | 我的博客前端 |
| 授权服务器(Authorization Server) | GitHub OAuth |
| 资源服务器(Resource Server) | GitHub API(获取用户信息) |
标准流程:
1. 前端引导用户去 GitHub:/"嘿,去 GitHub 那授权一下"
→ 跳转 https://github.com/login/oauth/authorize?client_id=xxx
2. 用户在 GitHub 上点"授权"
→ GitHub 给一个临时 code,回调到我的服务器
3. 服务器拿 code 去换 access_token:
→ POST https://github.com/login/oauth/access_token { code, client_id, client_secret }
4. 服务器用 access_token 查用户信息:
→ GET https://api.github.com/user (Authorization: Bearer access_token)
5. 服务器查到自己数据库,创建或更新用户 → 签发 JWT → 告诉前端"登录成功"
为什么要有 code 这一步? 因为 GitHub 不能直接把 access_token 给前端浏览器(不安全),所以先给一个一次性的 code,由后端服务器用 client_secret 去换 token——client_secret 只有服务器知道。
具体例子:小明登录博客
用一个小明的例子走一遍完整流程,每一步的数据变化都写出来。
场景:小明首次使用 GitHub 登录博客
开始 — 小明打开博客首页,点击"GitHub 登录"按钮。
Step 1 获取地址 — 前端向后端请求 GitHub 授权地址。
GET /api/auth/github
→ 返回 { url: "https://github.com/login/oauth/authorize?client_id=Iv23li...&redirect_uri=..." }
→ 前端执行 window.location.href = url
Step 2 用户授权 — 浏览器跳转到 GitHub 授权页面,小明看到:
pc-blog App 想要访问你的公开信息
点击 Authorize,同意授权。
Step 3 GitHub 回调 — GitHub 生成一个一次性临时 code,回调到后端。
URL: https://api.blog.com/auth/callback?code=abc123xyz
这个 code 5 分钟内有效,只能用一次。
Step 4 换 token — 后端拿着 code 去找 GitHub 换 access_token。
POST https://github.com/login/oauth/access_token
请求:{ client_id, client_secret, code: "abc123xyz" }
返回:{ access_token: "ghu_xxxxxxxx", expires_in: 28800 }
Step 5 查用户信息 — 后端用 access_token 获取小明在 GitHub 上的公开信息。
GET https://api.github.com/user
Header: Authorization: Bearer ghu_xxxxxxxx
返回:{ id: 12345678, login: "xiaoming99", avatar_url: "https://..." }
Step 6 创建本地用户 — 后端查数据库,发现 github_id=12345678 不存在,说明小明是第一次来。
INSERT INTO user (username, nickname, github_id, avatar, github_token)
VALUES ("gh_xiaoming99", "xiaoming99", "12345678", "https://...", "ghu_xxxxxxxx")
Step 7 签发 JWT — 后端为小明签发博客自己的登录凭证。
JWT payload:{ sub: "42", nickname: "xiaoming99", iat: 1719360000, exp: 1719964800 }
→ 重定向到 https://blog.com/auth/callback?token=eyJhbGciOi...
Step 8 前端收尾 — 前端 callback 页面接收到 token,完成登录。
→ 存 localStorage:{ token: "eyJhbGciOi...", user: { id: 42, nickname: "xiaoming99", avatar: "https://..." } }
→ 更新全局登录状态 → 跳转首页
结束 — 小明看到首页右上角已显示他的 GitHub 头像和用户名,登录完成。
如果小明下次再来(已注册用户)
第二次点击 GitHub 登录时,前面的步骤完全一样,只有 Step 6 不同——不是创建新用户,而是更新已有信息:
UPDATE user SET nickname="xiaoming99", avatar="...", github_token="..." WHERE id=42
小明无感知,每次都是一键登录。
关键细节
| 步骤 | 容易踩坑的地方 |
|---|---|
| Step 3 的 code | 一次性使用,用户刷新回调 URL 会报错,需要处理重复回调 |
| Step 4 的 client_secret | 只存在后端环境变量里,绝不能暴露在前端代码中 |
| Step 7 的 JWT 通过 URL 传递 | URL 可能被服务器日志记录,生产环境建议改用 POST 方式 |
| Step 8 的 token 存 localStorage | XSS 攻击可能导致 token 泄露,注意配置 CSP 策略 |
核心实现:接口与流程拆解
以下是后端与登录相关的 4 个核心接口,每个只展示关键逻辑。
1. 获取 GitHub 登录地址
GET /api/auth/github
返回:{ url: "https://github.com/login/oauth/authorize?..." }
// 组装 GitHub OAuth 授权页面的 URL
function getGithubUrl(clientId: string, redirectUri: string): string {
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=user:email`;
}
// 前端拿到 URL 后直接跳转:
// window.location.href = url
2. GitHub OAuth 回调(核心中的核心)
GET /api/auth/github/callback?code=xxx
→ 用 code 换 access_token
→ 查 GitHub 用户信息
→ 查找或创建本地用户
→ 签发博客 JWT
→ 302 重定向到前端 callback 页
这是整个流程最关键的一步,后端做了四件事:
Step 1:用 code 换 access_token
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json" },
body: new URLSearchParams({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: env.GITHUB_REDIRECT_URI,
}),
});
const { access_token, refresh_token, expires_in } = await tokenRes.json();
Step 2:获取 GitHub 用户信息
const userRes = await fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${access_token}` },
});
const ghUser = await userRes.json();
// ghUser: { id, login, avatar_url, email }
Step 3:查找或创建本地用户
// 按 github_id 查找已有用户
let user = await db.prepare(
"SELECT id, nickname, avatar FROM user WHERE github_id = ? AND deleted = 0"
).bind(String(ghUser.id)).first();
if (!user) {
// 首次登录 → 创建新用户,记录 GitHub Token 以备后用(如评论)
const result = await db.prepare(
`INSERT INTO user (username, password, nickname, github_id, avatar,
github_token, github_refresh_token, github_token_expires_at)
VALUES (?, '', ?, ?, ?, ?, ?, ?)`
).bind(
`gh_${ghUser.login}`, ghUser.login, String(ghUser.id),
ghUser.avatar_url, access_token,
refresh_token || null,
expires_in ? String(Date.now() + expires_in * 1000) : null
).run();
user = { id: result.meta.last_row_id, nickname: ghUser.login, avatar: ghUser.avatar_url };
} else {
// 已存在 → 更新昵称、头像和 Token
await db.prepare(
`UPDATE user SET nickname=?, avatar=?, github_token=?,
github_refresh_token=?, github_token_expires_at=? WHERE id=?`
).bind(ghUser.login, ghUser.avatar_url, access_token,
refresh_token || null,
expires_in ? String(Date.now() + expires_in * 1000) : null,
user.id
).run();
}
Step 4:签发 JWT 并重定向
const jwt = await signJwt(
{ sub: String(user.id), nickname: user.nickname },
env.JWT_SECRET,
604800 // 7 天有效期
);
// 重定向回前端 callback 页面,URL 中携带 JWT
return Response.redirect(
`${env.FRONTEND_URL}/auth/callback?token=${jwt}`,
302
);
3. 账号密码登录(与 GitHub 登录平级)
POST /api/auth/login { username, password }
→ 验证密码 → 签发 JWT → 返回 token + user
// 查用户
const row = await db.prepare(
"SELECT * FROM user WHERE username = ? AND deleted = 0"
).bind(username).first();
if (!row || !bcrypt.compareSync(password, row.password)) {
return { code: 0, msg: "用户名或密码错误" };
}
// 签发 JWT
const token = await signJwt(
{ sub: String(row.id), nickname: row.nickname },
env.JWT_SECRET,
604800
);
return {
code: 1,
data: { token, user: { id: row.id, nickname: row.nickname, avatar: row.avatar } }
};
两种登录方式最终走的同一套 JWT 和 user 表,后续可以无缝添加微信扫码等其他登录方式。
4. 获取当前用户信息
GET /api/auth/me
Header: Authorization: Bearer <jwt>
→ 验证 JWT → 查 D1 → 返回 user
前端 Callback 页面
GitHub 重定向回来后,前端处理收尾工作:
// /auth/callback?token=xxx
const token = new URLSearchParams(window.location.search).get("token");
if (!token) { router.replace("/auth/login"); return; }
// 存到 localStorage
localStorage.setItem("token", token);
// 用 token 获取用户信息
const user = await apiFetch("/api/auth/me");
// 存到 zustand store,全局可用
useAuthStore.getState().setAuth(token, user);
router.replace("/");
5. 前端 API 请求工具
所有请求自动带上 JWT,遇到 401 自动清除登录态:
async function apiFetch(path: string, options: RequestInit = {}) {
const token = localStorage.getItem("token");
const res = await fetch(`${API_BASE}${path}`, {
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...options,
});
if (res.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("blog-user");
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new Error("登录已过期");
}
const json = await res.json();
if (json.code !== 1) throw new Error(json.msg);
return json.data;
}
关键设计说明
1. JWT 和 GitHub Token 分开管理
| Token | 用途 | 有效期 | 存在哪 |
|---|---|---|---|
| 博客 JWT | 登录凭证,标识"你是谁" | 7 天 | localStorage |
| GitHub Access Token | 调 GitHub API 做评论等操作 | 见下方说明 | D1 数据库 |
两者互不依赖:没有 GitHub Token 的用户(密码登录)一样能拿 JWT,只是无法以本人身份调 GitHub API。
2. GitHub Token 过期与刷新
如果开启 Expire user authorization tokens,access_token 8 小时过期。后端在评论功能用到时会检查有效期,过期则用 refresh_token 自动刷新,用户无感知。
建议关闭过期——不关也行,逻辑已经处理好了,只是多一次刷新请求。
3. 登录态恢复无闪烁
页面加载时直接从 localStorage 读取 token 和用户信息,不经过 API 调用。这样页面刷新时用户不会看到"未登录"状态一闪而过。
与评论功能的关系
这套登录系统同时也是评论功能的基础:
- GitHub 用户:登录时保存了
github_token→ 评论时可以用这个 token 以本人身份发 - 密码用户:登录后没有
github_token→ 评论时由系统 App 代发,body 嵌入用户身份信息 - 两种用户的昵称和头像都来自
user表,评论前端统一展示
途中遇到的问题
| 问题 | 原因 | 解决 |
|---|---|---|
| GitHub OAuth 回调 404 | Callback URL 配置错误 | 确保后端路由和 GitHub App 设置中的 Callback URL 完全一致(包括协议) |
| code 换 token 失败 | code 一次性使用,用户重复回调 | 回调成功后在 URL 中去掉 code 参数,或加 state 校验 |
| JWT 签名不对 | 密钥不匹配 | 检查 JWT_SECRET,前后端使用同一个 |
| 登录后评论显示为 bot | 忘记在登录时保存 github_token | 检查回调逻辑中是否将 access_token 写入了 D1 |
| token 过期后 API 返回 401 | 没有在 apiFetch 中处理 401 | 在拦截器中清除 localStorage 并跳转登录页 |
参考资料
🌐 欢迎关注我的其他平台:
📧 联系我:mail@lxpavilion.top

浙公网安备 33010602011771号