前言

这个博客的评论区、说说、杂谈都用了 GitHub OAuth 作为登录方式。为什么选 GitHub 而不是自己搞一套邮箱注册?

  • 用户不需要额外注册账号,有 GitHub 账号就能评论
  • 不用处理邮箱验证、密码重置、短信验证码这些烦人的东西
  • GitHub 头像和用户名天然就是用户身份标识,评论区看起来整洁

接入流程不复杂,但查资料的时候发现中文博客要么讲得太浅,要么代码太老。这篇把完整的实现写清楚,后端 FastAPI,前端 Next.js,从零到跑通。

1. 注册 GitHub OAuth App

首先去 GitHub 注册 OAuth App:

  1. 打开 Settings → Developer settings → OAuth Apps → New OAuth App
  2. 填写:
    • 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 的接入实际上就三个步骤:

  1. 跳转到 GitHub 让用户授权
  2. 用回调的 code 换 access_token,再换用户信息
  3. 生成自己的 JWT,后续请求携带认证

后端用 FastAPI + httpx + jose,前端用 Next.js + localStorage,代码量不大但完整的认证链路跑通了。在三个业务模块(评论、说说、杂谈)中复用同一套认证逻辑,每个页面只需要几十行代码就能接入。

这种方式的优势很明显:用户零注册成本,数据完全自控,不依赖第三方评论系统。劣势是 GitHub 在国内偶尔会被墙,不过对于开发者为主的博客来说,这个权衡是值得的。