模块四:后端 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 仍开放——攻击者直接 curl。API 必须重复校验,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 登录减少密码管理成本;注意 PKCE 与 redirect 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 需要
accounts、sessions、verification_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 永远带 userId;updateNote 用 and(eq(noteId), eq(userId)) 双条件,避免 IDOR(不安全的直接对象引用)。
七、对照参考:安全清单(浓缩版)
.env*进.gitignore;仓库用gitleaks/trufflehog做 CI 扫描。- Client Component 绝不读取
process.env中带密钥的变量;仅NEXT_PUBLIC_*可暴露。 - Cookie 设置
Secure、HttpOnly、SameSite(具体由框架/部署代理协同)。 - OAuth Redirect URI 精确匹配,不用通配符偷懒。
- 管理后台与公开 API 分路由前缀,权限模型分轨。
mindmap
root((VibeNote 安全))
Secrets
服务端环境变量
CI Secret Store
轮换流程
Transport
HTTPS 强制
HSTS
App
Middleware 拦截
Handler 二次校验
IDOR 防护
Supply
依赖审计
lockfile 固定版本
八、思考题
- 为什么「只隐藏前端按钮」无法替代 API 授权?举一个具体的伪造请求例子。
- Session 与 JWT 哪种更适合「立刻封禁用户」?如何实现?
- Middleware matcher 配置错误会导致哪些难排查现象(如静态资源 401)?
九、本节小结
- 认证解决身份,授权解决资源访问;API 必须双重校验。
- Auth.js 把 OAuth、Cookie、回调路由标准化;密钥管理是生死线。
- Middleware 做轻量前置拦截,Handler 做业务级授权。
- 参考材料中的泄露案例说明:一次 push 的代价可能是真金白银。
十、下一讲预告
第05讲:数据校验三道防线——为什么只做前端校验很危险
我们将用 Zod 定义「笔记创建/更新」的 schema,在 Route Handler 中做服务端校验,并用数据库约束兜底;同时设计统一的 400 错误反馈,让前端能把「人话」展示给用户,而不是「Unknown error」。