为博客搭建 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(手动生成)

image.png

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

posted @ 2026-07-16 20:26  PC2005-cloud  阅读(7)  评论(0)    收藏  举报