前言
这个博客的评论区、说说、杂谈都用了 GitHub OAuth 作为登录方式。为什么选 GitHub 而不是自己搞一套邮箱注册?
- 用户不需要额外注册账号,有 GitHub 账号就能评论
- 不用处理邮箱验证、密码重置、短信验证码这些烦人的东西
- GitHub 头像和用户名天然就是用户身份标识,评论区看起来整洁
接入流程不复杂,但查资料的时候发现中文博客要么讲得太浅,要么代码太老。这篇把完整的实现写清楚,后端 FastAPI,前端 Next.js,从零到跑通。
1. 注册 GitHub OAuth App
首先去 GitHub 注册 OAuth App:
- 打开 Settings → Developer settings → OAuth Apps → New OAuth App
- 填写:
- Application name: 随便填,用户授权页会看到
- Homepage URL: 你的前端地址
- Authorization callback URL: 后端回调地址,比如
https://api.yourdomain.com/api/auth/github/callback
注册后会得到 Client ID 和 Client Secret,后者只显示一次,记得保存。
2. 后端配置
环境变量
# app/config.py
import os
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
SECRET_KEY = os.environ.get("SECRET_KEY", "your-secret-key")
ALGORITHM = "HS256"
.env 文件:
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
SECRET_KEY=随便一个随机字符串
FRONTEND_ORIGIN=https://你的前端域名
数据库模型
用 SQLModel 存 GitHub 用户信息:
# app/models/github_user.py
from sqlmodel import SQLModel, Field
class GitHubUser(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
github_id: int = Field(unique=True, index=True) # GitHub 的数字 ID
login: str # GitHub 用户名
avatar: str = "" # 头像 URL
bio: str = "" # 个人简介
created_at: str = ""
评论、说说、杂谈的模型都通过 github_user_id 外键关联到这个表:
class Comment(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
github_user_id: int | None = Field(default=None, foreign_key="githubuser.id")
content: str
# ... 其他字段
核心 OAuth 流程
OAuth 的认证入口和回调处理集中在一个文件里:
# app/api/github_auth.py
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session
from jose import jwt
import httpx
router = APIRouter(prefix="/api/auth/github", tags=["GitHub 登录"])
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "https://boke.hiromu.top")
第一步:跳转到 GitHub 授权页
@router.get("/login")
def github_login():
"""跳转到 GitHub 授权页"""
url = (
f"https://github.com/login/oauth/authorize"
f"?client_id={GITHUB_CLIENT_ID}"
f"&scope=read:user"
)
return RedirectResponse(url)
scope=read:user 只请求读取用户基本信息(用户名、头像),不需要仓库权限。
第二步:回调处理——code 换 token,token 换用户信息
@router.get("/callback")
def github_callback(code: str, session: Session = Depends(get_session)):
# 1. 用 code 换 access_token
token_resp = httpx.post(
"https://github.com/login/oauth/access_token",
json={
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
"code": code,
},
headers={"Accept": "application/json"},
timeout=10,
)
token_data = token_resp.json()
access_token = token_data.get("access_token")
# 2. 获取 GitHub 用户信息
user_resp = httpx.get(
"https://api.github.com/user",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
gh_user = user_resp.json()
# 3. 查找或创建本地用户
existing = session.exec(
select(GitHubUser).where(GitHubUser.github_id == gh_user["id"])
).first()
if existing:
existing.login = gh_user["login"]
existing.avatar = gh_user.get("avatar_url", "")
existing.bio = gh_user.get("bio") or ""
session.add(existing)
session.commit()
session.refresh(existing)
db_user = existing
else:
db_user = GitHubUser(
github_id=gh_user["id"],
login=gh_user["login"],
avatar=gh_user.get("avatar_url", ""),
bio=gh_user.get("bio") or "",
)
session.add(db_user)
session.commit()
session.refresh(db_user)
# 4. 签发 JWT,重定向回前端
token = jwt.encode(
{"sub": str(db_user.id), "login": db_user.login, "type": "github"},
SECRET_KEY,
algorithm=ALGORITHM,
)
redirect_url = f"{FRONTEND_ORIGIN}/auth/callback?token={token}"
return RedirectResponse(redirect_url)
这里要注意几个点:
- code 是一次性的,用一次就失效,所以必须立即换 access_token
- 用
httpx而不是requests,因为 FastAPI 异步兼容,且 httpx 的 API 比 requests 更现代 - 本地数据库 upsert 逻辑:如果已存在用户就更新信息,不存在则创建
JWT 验证
JWT 签发之后,后续的请求通过 Authorization header 携带 token:
@router.get("/me")
def get_me(request: Request, session: Session = Depends(get_session)):
"""获取当前登录的用户信息"""
user = _get_github_user(request, session)
return {
"id": user.id,
"login": user.login,
"avatar": user.avatar,
"bio": user.bio,
}
def _get_github_user(request: Request, session: Session) -> GitHubUser:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(401, "未登录")
token = auth[7:]
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload["sub"])
user = session.get(GitHubUser, user_id)
if not user:
raise HTTPException(401, "用户不存在")
return user
还有一个「可选登录」的版本,用于未登录也能查看文章的场景:
def get_github_user_optional(request: Request, session: Session) -> GitHubUser | None:
"""可选登录:未登录返回 None,不抛 401"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
try:
token = auth[7:]
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload["sub"])
return session.get(GitHubUser, user_id)
except Exception:
return None
在其他 API 中使用
以评论接口为例:
# app/api/comments.py
@router.post("/comments")
def create_comment(
comment_data: CommentCreate,
request: Request,
session: Session = Depends(get_session),
):
gh_user = get_github_user_optional(request, session)
if not gh_user:
raise HTTPException(401, "请先登录")
# 创建评论,关联 gh_user.id
...
3. 前端实现
统一的回调处理页
后端回调完成后重定向到 /auth/callback?token=xxx,前端做两件事:存 token、跳回原页面。
// app/auth/callback/page.tsx
"use client";
import { useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
export default function AuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const token = searchParams.get("token");
if (token) {
localStorage.setItem("github_token", token);
// 回到登录前的页面
const redirectTo = sessionStorage.getItem("github_redirect") || "/messages";
sessionStorage.removeItem("github_redirect");
router.replace(redirectTo);
} else {
router.replace("/messages");
}
}, [searchParams, router]);
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-sky-500 animate-spin" />
</div>
);
}
关键设计:用 sessionStorage 存跳转前路径而不是 localStorage,好处是登录完成后自动清除,不会残留。
登录/登出
以评论区为例:
// 登录
function handleLogin() {
// 记住当前页面,登录后跳回来
sessionStorage.setItem("github_redirect", window.location.pathname);
window.location.href = "/api/auth/github/login";
}
// 登出
function handleLogout() {
localStorage.removeItem("github_token");
localStorage.removeItem("github_user");
setUser(null);
}
发起带认证的 API 请求
// app/api/comments.ts
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("github_token");
}
export async function createComment(
slug: string,
content: string,
parentId?: number
): Promise<CommentItem> {
const token = getToken();
const res = await fetch(`/api/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ slug, content, parent_id: parentId }),
});
return res.json();
}
验证登录状态
页面加载时验证 token 是否有效:
useEffect(() => {
const savedToken = localStorage.getItem("github_token");
if (!savedToken) return;
getGithubUser(savedToken)
.then((user) => {
setUser(user);
localStorage.setItem("github_user", JSON.stringify(user));
})
.catch(() => {
// token 过期或无效,清除登录状态
localStorage.removeItem("github_token");
localStorage.removeItem("github_user");
setUser(null);
});
}, []);
这里缓存在 localStorage 里是为了避免每次页面刷新都重新请求 /me 接口,但启动时仍然会验证一次。
4. 完整流程图
用户点击"GitHub 登录"
│
▼
前端保存当前路径到 sessionStorage
│
▼
跳转到 /api/auth/github/login
│
▼
后端 302 重定向到 GitHub 授权页
│
▼
用户在 GitHub 上授权
│
▼
GitHub 302 回调到 /api/auth/github/callback?code=xxx
│
├── POST github.com/login/oauth/access_token (code → token)
├── GET api.github.com/user (获取用户信息)
├── upsert 本地数据库 GitHubUser 记录
├── 签发 JWT
│
▼
302 重定向到前端 /auth/callback?token=xxx
│
▼
前端存储 token 到 localStorage
│
▼
跳转回原始页面
│
▼
后续请求携带 Authorization: Bearer xxx
5. 一些注意点
JWT 过期时间:这个项目里 GitHub OAuth 签发的 JWT 没有设置过期时间。对于博客评论这种低风险场景问题不大,如果业务需要可以加 exp 声明:
from datetime import datetime, timedelta
payload = {
"sub": str(db_user.id),
"login": db_user.login,
"type": "github",
"exp": datetime.utcnow() + timedelta(days=30),
}
token 存储:localStorage 存储 JWT 最简单的方案。更安全的是 httpOnly cookie,但需要后端设置 cookie 且要求同域部署。个人博客选 localStorage 够用。
回调地址不一致:GitHub OAuth 的 callback URL 必须和注册时填写的完全匹配(包括协议、端口、路径)。开发环境可以用 http://localhost:8000,生产环境用正式域名。我本地开发时也踩过这个坑。
作用域最小化:scope=read:user 就够了,不需要 repo 或 admin 权限,用户授权时也不会被 GitHub 的权限列表吓到。
总结
GitHub OAuth 的接入实际上就三个步骤:
- 跳转到 GitHub 让用户授权
- 用回调的 code 换 access_token,再换用户信息
- 生成自己的 JWT,后续请求携带认证
后端用 FastAPI + httpx + jose,前端用 Next.js + localStorage,代码量不大但完整的认证链路跑通了。在三个业务模块(评论、说说、杂谈)中复用同一套认证逻辑,每个页面只需要几十行代码就能接入。
这种方式的优势很明显:用户零注册成本,数据完全自控,不依赖第三方评论系统。劣势是 GitHub 在国内偶尔会被墙,不过对于开发者为主的博客来说,这个权衡是值得的。

