博客的后台需要权限校验,我最终选型用了 GitHub OAuth 来实现。这篇文章说明为什么选它,以及具体怎么做。文中代码节选自本博客的真实实现(Next.js 16 App Router + Auth.js v5)。
一、为什么用 GitHub OAuth 做权限校验
首先是需求很小,整个博客后台的管理员只有我自己,没有注册,没有角色权限。在实现的过程中,我排除了几个其他的选项:
- 自己写账号密码登录。单管理员场景下看着挺简单的,但是要自己解决凭证存储(bcrypt)、登录限速、会话固定攻击、忘记密码这些操作,反正就一个用户,做这些好像没太必要。
- Supabase Auth。排除这个是因为现在博客的架构方案是纯白嫖的,我不想绑死在一些第三方平台上,目前只用了 Supabase 来存储数据,权限认证也走它的话就又多了一个耦合点了。因为想着以后方便迁移平台,说不定哪天迁到 cloudflare 上,或者租云服务器自己搭建服务了,就不用太多第三方的东西了。
因此最终选了 GitHub OAuth,本身代码就放在 GitHub 上,凭证管理、二步验证、异地登录提醒这些能力都是现成的,我只需要做一个邮箱白名单即可,拥有白名单的邮箱登录,才是管理员账户,才会放行登录。
二、授权认证流程与时序图
接入之前,先把 OAuth 2.0 授权码流程(Authorization Code Flow)完整走一遍。后面每一行代码都对应图上的某一步。
参与方有三个:浏览器(用户)、我的服务器(Next.js)、GitHub(授权服务器 + 资源服务器)。从点击登录到进入后台,完整走完是这样:
浏览器 我的服务器 (Next.js) GitHub
│ │ │
│ 1. GET /admin(未登录) │ │
│ ──────────────────────────────────>│ │
│ <──── 302 → /auth/signin ──────────│ proxy.ts 乐观拦截 │
│ │ │
│ 2. 点「使用 GitHub 登录」 │ │
│ ── POST /api/auth/signin/github ──>│ │
│ │ 生成随机 state │
│ <──── 302 → GitHub 授权页 ──────────│ 并写入 httpOnly cookie │
│ │ │
│ 3. GET github.com/login/oauth/authorize │
│ ?client_id=…&state=…&scope=read:user user:email │
│ &redirect_uri=https://mysite.com/api/auth/callback/github │
│ ──────────────────────────────────────────────────────────────────>│
│ │ 4. 用户在 GitHub 登录并授权 │
│ <── 302: /api/auth/callback/github?code=…&state=… ────────────────│
│ │ │
│ 5. GET /api/auth/callback/github │ │
│ ──────────────────────────────────>│ 6. 比对 state: │
│ │ URL 参数 === cookie 值? │
│ │ │
│ │ 7. POST /login/oauth/access_token
│ │ code + client_id + secret │
│ │ ────────────────────────────>│
│ │ <────── { access_token } ────│
│ │ │
│ │ 8. GET api.github.com/user │
│ │ (Authorization: Bearer …) │
│ │ ────────────────────────────>│
│ │ <──── { email, login, … } ───│
│ │ │
│ │ 9. signIn() 回调: │
│ │ 邮箱在白名单里吗? │
│ │ 10. 签发会话 cookie,302 回跳 │
│ <──── 302 → /auth/callback?next=/admin ──────────│ │
│ │ │
│ 11. GET /auth/callback?next=/admin │ │
│ ──────────────────────────────────>│ isAdmin()? → 302 /admin │
│ <──────────────────────────────────│ │图中有三个点比较重要的:
- 中间换一张授权码,不直接返回 access token:因为 access token 是敏感凭证,而第4步的重定向经过浏览器,授权码是一次性、短时效的中间凭证,真正换取 token 的第7步发生在服务器之间,
client_secret永远不进浏览器的,这是安全的核心保障。 - 第8步还要再请求一次 GitHub:第 7 步拿到的 access token 只是「访问许可」,用户信息(尤其是邮箱)要再拿 token 去调 /user 接口。我的白名单比对的就是这里的 email 字段。
- 会话是 OAuth 体系之外的产物。 OAuth 流程的终点只是一个「已验证的邮箱」,用什么机制维持登录态(cookie、JWT、数据库会话)是你应用自己的事。Auth.js 替我们做了,但边界在哪,心里得有数。
三、接入 Auth.js v5
整个认证核心就一个 src/auth.ts:
import NextAuth, { customFetch } from "next-auth";
import GitHub from "next-auth/providers/github";
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
// resilientFetch 见第八节(境内网络重试),先当普通 fetch 看
const githubProvider = GitHub({
[customFetch]: resilientFetch,
});
export const { handlers, auth } = NextAuth({
trustHost: true,
providers: [githubProvider],
pages: {
signIn: "/auth/signin", // 用自己的登录引导页替换默认页
},
callbacks: {
// 只放行管理员邮箱;其他 GitHub 用户直接拒绝
async signIn({ user, account }) {
if (account?.provider !== "github") return false;
if (!user?.email) return false;
return ADMIN_EMAILS.includes(user.email.toLowerCase());
},
},
});
// 后端兜底:Server Component / Server Action / Route Handler 里用
export async function isAdmin() {
const session = await auth();
if (!session?.user?.email) return false;
return ADMIN_EMAILS.includes(session.user.email.toLowerCase());
}几个值得展开的细节:
signIn 回调是白名单的唯一裁决点。 Auth.js 完成 token 交换、拿到用户信息之后会调用它,返回 false 整个登录就地失败,GitHub 用户会被重定向回登录页并带上 ?error=AccessDenied。我在登录页把这个错误翻译成人话:「该 GitHub 账号没有管理员权限」。
白名单比对做了归一化。 ADMIN_EMAILS 在模块加载时 trim + 转小写,比对时 user.email 同样转小写。环境变量这种手填的东西,永远不要相信它的大小写和空格。
isAdmin() 是给服务端用的兜底函数。 它和 signIn 回调长得很像,但作用完全不同:signIn 只在 OAuth 握手那一刻跑一次,而 isAdmin() 每次调用都会实时解会话、查白名单。这意味着把我从 ADMIN_EMAILS 里删掉,已登录的会话下一次请求就会失效——白名单是活的,不是登录时烧进 cookie 的。没有数据库用户表时,这是最省心也最不容易出错的做法。
邮箱为空直接拒绝。 GitHub 用户可以把主邮箱设为私有,这时 user.email 可能为空。默认 scope(read:user user:email)通常能拿到主邮箱,但稳妥起见,拿不到邮箱 = 无法比对白名单 = 拒绝,而不是放行。
四、三层防线 + 数据库墙
登录解决「你是谁」,接下来的问题是「每个入口都查了吗」。我的后台有四层,从外到内:
请求进入
│
├─ ① proxy.ts(Next 16 的 middleware)
│ 乐观拦截:没有会话 cookie 就跳登录页
│
├─ ② admin/layout.tsx
│ 真鉴权:auth() 解会话 → isAdmin() → 不过就 redirect
│
├─ ③ 每个 Server Action 开头
│ if (!(await isAdmin())) return { message: "未授权" };
│
└─ ④ 数据库 RLS
匿名角色只能 SELECT published = true 的文章先看第1层。Next.js 16 里 middleware 更名成了 proxy.ts,我用 Auth.js 导出的 auth 把它包起来:
export const proxy = auth((req) => {
const isLoggedIn = !!req.auth?.user;
if (!isLoggedIn && req.nextUrl.pathname.startsWith("/admin")) {
const url = req.nextUrl.clone();
url.pathname = "/auth/signin";
// 带上原始路径 + 查询串,登录后能回到被打断的地方
url.searchParams.set("redirectTo", `${req.nextUrl.pathname}${req.nextUrl.search}`);
return NextResponse.redirect(url);
}
return NextResponse.next();
});
export const config = {
matcher: ["/admin/:path*"],
};为什么叫「乐观拦截」?因为它只判断「有没有会话 cookie」,不验证会话内容、不查白名单。伪造一颗 cookie 就能骗过它——但骗过它只会让你看到跳转到2,2会把你打回去。它的价值是让未登录用户少跑一次完整渲染。
第2层是真正的页面守门人:
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// 真正的鉴权在这里(proxy 只是乐观跳转)
if (!(await isAdmin())) {
redirect("/auth/signin?redirectTo=/admin");
}
return <AdminShell>{children}</AdminShell>;
}第3层最容易被漏掉:Server Actions 是独立的 HTTP 端点,不经过页面鉴权。攻击者完全可以不打开 /admin/posts 页面,直接向 action 端点发请求。所以每个写操作的开头都要再问一次:
"use server";
export async function createPost(formData: FormData) {
if (!(await isAdmin())) return { message: "未授权" };
// …
}第4层数据库 RLS 是最后的墙,属于「纵深防御」:我的 Drizzle 连接用户是表 owner(绕过 RLS,写权限由应用层保证),而匿名角色只被允许读已发布文章。就算前三层全部失守,攻击者拿到的匿名会话也读不到草稿。
五、回调之后:会话是怎么建立的
OAuth 握手成功只是起点,真正的登录态是 Auth.js 在回调末尾建立的。拆开第 5–10 步看,/api/auth/callback/github 内部依次做了:
- 比对 state;
- 用 code 换 access token——服务器对 GitHub 的 POST,带上
client_secret,浏览器全程不可见; - 拿 token 请求
/user,得到邮箱等用户信息; - 跑
signIn回调——白名单不通过就到此为止; - 签发会话:没有配置数据库 adapter 时,Auth.js 默认走 JWT 策略——把会话数据打包成一个 JWE 加密的 cookie(
authjs.session-token,httpOnly、SameSite=Lax、生产环境 Secure),加密密钥派生自AUTH_SECRET; - 302 重定向到
callbackUrl。
之后每一个请求里,auth() 干的事就是:读 cookie → 用 AUTH_SECRET 解密 → 校验有效期 → 还原出 session.user.email。没有数据库查询,会话完全自包含——这也是它快的原因。
六、部署清单
最后把上线要动的地方收拢成一张清单:
| 项 | 值 / 操作 |
|---|---|
| GitHub OAuth App 回调地址 | https://你的域名/api/auth/callback/github(与实际域名严格一致,差一个 www 都会报 redirect_uri_mismatch) |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET | OAuth App 的 Client ID / Client Secret |
AUTH_SECRET | openssl rand -base64 32;泄露 = 会话可伪造,轮换 = 全站登出 |
AUTH_URL | 与站点域名一致(注意 www) |
ADMIN_EMAILS | 你的 GitHub 主邮箱,逗号分隔;代码里会 trim + 转小写 |
trustHost: true | 部署在反向代理后必须显式开启 |
结语
回头看,这个「只有我能进的后台」,我真正自己写的代码不到 200 行:一个白名单回调、一个分层鉴权、一个重定向校验、一个带重试的 fetch。凭证存储、密码学、CSRF、会话管理全部由 OAuth 体系与 Auth.js 承担。但是我总觉得我的做法还是略显粗暴,比如我需要在每个管理员接口操作手动写判断 isAdmin(),太不优雅了,这套东西将来可能会重构,现在有AI了,很多东西只要有想法,随时可以执行哈哈,以上。
