4.4 认证与授权——用户系统怎么做才稳?中间件策略实战

2 阅读5分钟

模块四:后端 API 与数据管理 | 第04讲:认证与授权——用户系统怎么做才稳?中间件策略实战

本讲定位:分清认证(Authentication)与授权(Authorization),用成熟方案落地 VibeNote 用户体系,并用 Middleware 把「保护面」前置。
项目锚点:Next.js 14 App Router + TypeScript。
阅读线索:课程内容《6.2 认证与权限怎么做才稳:中间件策略实战》;参考 reference/advanced/08-auth-security.md(密钥泄露案例与路由保护清单)。


一、开篇:先讲一个「推送即泄露」的故事

参考材料里的小明把 .env 推上公开仓库,OpenAI Key 在几分钟内被扫走——这不是个案,是可脚本化的黑产流水线。对 VibeNote 来说,真正的雷区还包括:

  • DATABASE_URL 泄露 → 全库读写。
  • AUTH_SECRET 过弱或复用 → 会话伪造。
  • OAuth Client Secret 进了前端 bundle → 第三方账号体系被劫持。

结论:认证与安全不是功能完成后的补丁,而是从第一天就要写的「基础设施」。你要做的,是选经过审计的方案,把密钥关进服务端与 CI 秘密存储。


二、认证 vs 授权:先认人,再谈能不能干

概念回答的问题VibeNote 例子
认证你是谁?登录成功 → 知道 userId
授权你能做什么?只能改自己的笔记,不能改别人的

常见反模式:只在 UI 隐藏按钮,但 API 仍开放——攻击者直接 curlAPI 必须重复校验,UI 只是体验层。

flowchart TD
    R[请求进入] --> M{Middleware 快速拦截}
    M -->|未登录且受保护路径| L[重定向登录/401]
    M -->|通过| H[Route Handler]
    H --> A{鉴权通过?}
    A -->|否| E401[401/403 + 统一错误体]
    A -->|是| B{授权: 资源属于用户?}
    B -->|否| E403[403]
    B -->|是| S[执行业务]

三、会话、JWT、OAuth:怎么为 VibeNote 选型?

3.1 Session(服务端会话)

浏览器持有 Cookie(HttpOnly),服务端保存会话记录或用加密会话 Cookie。优点是可立即失效、权限变更可生效快;缺点是多实例需共享存储或使用有状态粘性。

3.2 JWT(自包含令牌)

服务端用密钥签名 token,客户端每次携带 Authorization。优点是无中心化验证快;缺点是吊销困难(需短过期 + 刷新策略或黑名单)。

3.3 OAuth(第三方登录)

GitHub/Google 登录减少密码管理成本;注意 PKCEredirect URI 白名单,避免回调被劫持。

工程建议:Next.js 全栈项目优先使用 Auth.js(原 NextAuth.js) 与官方适配,减少自研密码学。

sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant N as Next.js
    participant P as GitHub OAuth

    U->>B: 点击「使用 GitHub 登录」
    B->>N: GET /api/auth/signin/github
    N->>P: 302 授权页
    P->>B: 用户同意
    P->>N: 回调 code
    N->>P: 用 client secret 换 token
    N->>N: 创建/关联 user + session
    N-->>B: Set-Cookie(session)

四、Auth.js(NextAuth v5)落地:文件清单与心智模型

版本迭代快,务必让 AI 对照你锁定的 next-auth@* 文档生成;下列示例展示 App Router 常见结构,可按包名差异微调 import。

4.1 依赖

pnpm add next-auth @auth/drizzle-adapter

(若使用官方 Drizzle Adapter;也可用 Prisma Adapter。)

4.2 auth.ts 配置骨架

// auth.ts(项目根或 src/auth.ts)
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import * as schema from "@/db/schema";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db, {
    usersTable: schema.users,
    // 视 adapter 版本补充 accounts/sessions/verificationTokens 表映射
  }),
  session: { strategy: "database" }, // 或 "jwt",按部署与规模选择
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
  ],
  callbacks: {
    async session({ session, user }) {
      if (session.user) session.user.id = user.id;
      return session;
    },
  },
});

教学说明:真实项目中 Auth.js 需要 accountssessionsverification_tokens 等表;你可以让 AI 基于官方 schema 生成迁移,不要手写弱随机 token 逻辑

4.3 Route Handler 挂载

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

4.4 环境变量(永远不要提交)

AUTH_SECRET="请使用 openssl rand -base64 32 生成"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
AUTH_GOOGLE_ID="..."
AUTH_GOOGLE_SECRET="..."
AUTH_TRUST_HOST=true

AUTH_SECRET 用于签名 Cookie 与令牌;轮换时要设计会话失效策略。


五、Middleware:把「未登录」挡在 Handler 之外

middleware.ts 位于项目根(或 src/middleware.ts),对匹配路径统一执行:

// middleware.ts
import { auth } from "@/auth";

export default auth((req) => {
  const { pathname } = req.nextUrl;
  const isAuthed = !!req.auth;

  if (!isAuthed && pathname.startsWith("/app")) {
    const url = req.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("callbackUrl", pathname);
    return Response.redirect(url);
  }
});

export const config = {
  matcher: ["/app/:path*", "/api/notes/:path*"],
};

要点

  • Middleware 里不要做重 IO(数据库复杂查询),保持轻量。
  • /api/* 与页面路由分别设计 matcher,避免把静态资源误伤。
  • Middleware 是「第一道门」,Route Handler 里仍要 按 userId 校验资源归属

六、API 内的授权模板(与 Drizzle 结合)

// app/api/notes/route.ts(片段)
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { createNoteWithTags, listNotesForUser } from "@/db/queries/notes";

export async function GET() {
  const session = await auth();
  const userId = session?.user?.id;
  if (!userId) {
    return NextResponse.json(
      { success: false, error: { message: "未登录" } },
      { status: 401 }
    );
  }
  const data = await listNotesForUser({ userId });
  return NextResponse.json({ success: true, data });
}

export async function POST(req: Request) {
  const session = await auth();
  const userId = session?.user?.id;
  if (!userId) {
    return NextResponse.json(
      { success: false, error: { message: "未登录" } },
      { status: 401 }
    );
  }
  const body = await req.json();
  const note = await createNoteWithTags({
    userId,
    title: body.title,
    contentMd: body.contentMd ?? "",
    tagNames: body.tagNames ?? [],
  });
  return NextResponse.json({ success: true, data: note }, { status: 201 });
}

授权体现在:listNotesForUser 永远带 userIdupdateNoteand(eq(noteId), eq(userId)) 双条件,避免 IDOR(不安全的直接对象引用)。


七、对照参考:安全清单(浓缩版)

  1. .env*.gitignore;仓库用 gitleaks/trufflehog 做 CI 扫描。
  2. Client Component 绝不读取 process.env 中带密钥的变量;仅 NEXT_PUBLIC_* 可暴露。
  3. Cookie 设置 SecureHttpOnlySameSite(具体由框架/部署代理协同)。
  4. OAuth Redirect URI 精确匹配,不用通配符偷懒。
  5. 管理后台与公开 API 分路由前缀,权限模型分轨。
mindmap
  root((VibeNote 安全))
    Secrets
      服务端环境变量
      CI Secret Store
      轮换流程
    Transport
      HTTPS 强制
      HSTS
    App
      Middleware 拦截
      Handler 二次校验
      IDOR 防护
    Supply
      依赖审计
      lockfile 固定版本

八、思考题

  1. 为什么「只隐藏前端按钮」无法替代 API 授权?举一个具体的伪造请求例子。
  2. Session 与 JWT 哪种更适合「立刻封禁用户」?如何实现?
  3. Middleware matcher 配置错误会导致哪些难排查现象(如静态资源 401)?

九、本节小结

  • 认证解决身份,授权解决资源访问;API 必须双重校验。
  • Auth.js 把 OAuth、Cookie、回调路由标准化;密钥管理是生死线。
  • Middleware 做轻量前置拦截,Handler 做业务级授权
  • 参考材料中的泄露案例说明:一次 push 的代价可能是真金白银

十、下一讲预告

第05讲:数据校验三道防线——为什么只做前端校验很危险
我们将用 Zod 定义「笔记创建/更新」的 schema,在 Route Handler 中做服务端校验,并用数据库约束兜底;同时设计统一的 400 错误反馈,让前端能把「人话」展示给用户,而不是「Unknown error」。