返回文章列表

Auth.js v5 接入 GitHub OAuth:做一个只有我能进的后台

3 次阅读

博客的后台需要权限校验,我最终选型用了 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(授权服务器 + 资源服务器)。从点击登录到进入后台,完整走完是这样:

TEXT
浏览器                          我的服务器 (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:

TypeScript
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)通常能拿到主邮箱,但稳妥起见,拿不到邮箱 = 无法比对白名单 = 拒绝,而不是放行。

四、三层防线 + 数据库墙

登录解决「你是谁」,接下来的问题是「每个入口都查了吗」。我的后台有四层,从外到内:

TEXT
请求进入
  │
  ├─ ① 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 把它包起来:

TypeScript
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层是真正的页面守门人:

TypeScript
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 端点发请求。所以每个写操作的开头都要再问一次:

TypeScript
"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 内部依次做了:

  1. 比对 state;
  2. 用 code 换 access token——服务器对 GitHub 的 POST,带上 client_secret,浏览器全程不可见;
  3. 拿 token 请求 /user,得到邮箱等用户信息;
  4. 跑 signIn 回调——白名单不通过就到此为止;
  5. 签发会话:没有配置数据库 adapter 时,Auth.js 默认走 JWT 策略——把会话数据打包成一个 JWE 加密的 cookie(authjs.session-token,httpOnly、SameSite=Lax、生产环境 Secure),加密密钥派生自 AUTH_SECRET;
  6. 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_SECRETOAuth App 的 Client ID / Client Secret
AUTH_SECRETopenssl rand -base64 32;泄露 = 会话可伪造,轮换 = 全站登出
AUTH_URL与站点域名一致(注意 www)
ADMIN_EMAILS你的 GitHub 主邮箱,逗号分隔;代码里会 trim + 转小写
trustHost: true部署在反向代理后必须显式开启

结语

回头看,这个「只有我能进的后台」,我真正自己写的代码不到 200 行:一个白名单回调、一个分层鉴权、一个重定向校验、一个带重试的 fetch。凭证存储、密码学、CSRF、会话管理全部由 OAuth 体系与 Auth.js 承担。但是我总觉得我的做法还是略显粗暴,比如我需要在每个管理员接口操作手动写判断 isAdmin(),太不优雅了,这套东西将来可能会重构,现在有AI了,很多东西只要有想法,随时可以执行哈哈,以上。