跳到主要内容

Next.js 鉴权、权限与 Proxy

这块很容易被混成一件事,但实际至少有三层:

  • Authentication:确认当前用户是谁
  • Session Management:让登录状态能跨请求延续
  • Authorization:决定当前用户能看什么、能做什么

如果只把它理解成“登录拦截”,后面很容易把所有逻辑都堆进 middleware.tsproxy.ts,最后既难维护,也不够安全。

先说当前主线

按照较新的 Next.js 官方文档,可以把鉴权相关逻辑分成几层:

1. 登录和会话建立

通常发生在:

  • 登录表单提交
  • OAuth 回调
  • Server Action
  • Route Handler

2. 乐观路由拦截

通常发生在:

  • proxy.ts
  • 旧项目里的 middleware.ts

这层适合做快速判断,例如:

  • 未登录就跳到 /login
  • 已登录访问公开登录页时跳回 /dashboard
  • 按 cookie 里的轻量 session 做初步分流

3. 真正的权限校验

应该尽量贴近数据访问层、Server Component、Route Handler 或 Server Action。

这才是防线核心。

为什么 Proxy 不是完整鉴权方案

Next.js 16 开始,官方把 Middleware 改名成了 Proxy,就是为了强调它更像“请求边界上的拦截层”,而不是万能中间件。

官方文档里有几个很重要的提醒:

  • Proxy 适合做 optimistic checks
  • 不应该承担完整 session 管理和授权逻辑
  • 不适合慢数据请求
  • Proxy 里使用 fetch 的缓存配置没有效果

所以比较稳的理解是:

  • Proxy 负责“先挡一下明显不该进来的请求”
  • 真正的数据访问权限,放在离数据更近的地方再检查一次

一个典型的分层方式

比较推荐的组织方式大概是这样:

app/
dashboard/
api/
lib/
auth/
session.ts
permissions.ts
dal.ts
proxy.ts

可以把职责拆成:

  • session.ts:创建、解密、刷新、删除 session
  • permissions.ts:角色和权限判断
  • dal.ts:带授权检查的数据访问入口
  • proxy.ts:只做路由级乐观拦截

这样一来,权限不会散落在每个页面里,也不会全部挤进 proxy.ts

Session 常见有两种路线

1. Stateless Session

通常用加密 cookie 存 session 信息。

优点:

  • 架构简单
  • 适合中小型应用
  • 不一定需要单独 session 表

代价:

  • 更新和失效策略要设计清楚
  • cookie 内容不能乱放敏感数据

2. Database Session

把 session 存数据库,cookie 里只放 session id。

优点:

  • 更容易集中管理
  • 更容易做吊销、强制下线、多端控制

代价:

  • 每次校验路径更长
  • 需要多一层存储和查询

proxy.ts 里适合做什么

比较适合:

  • 判断公开路由和受保护路由
  • 读取 cookie 做轻量 session 判断
  • 未登录跳转登录页
  • 已登录访问登录页时重定向
  • 做很轻的实验分流或租户前缀分流

一个最小例子

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const protectedRoutes = ['/dashboard', '/settings'];
const publicRoutes = ['/login', '/signup'];

export async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtectedRoute = protectedRoutes.some((route) =>
path.startsWith(route)
);
const isPublicRoute = publicRoutes.some((route) => path.startsWith(route));

const session = req.cookies.get('session')?.value;

if (isProtectedRoute && !session) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}

if (isPublicRoute && session) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
}

return NextResponse.next();
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

这个例子的重点不在“完整可用”,而在边界感:

  • 这里只做快速判断
  • 没有把复杂权限系统塞进去
  • 没有依赖慢查询

proxy.ts 里不适合做什么

1. 不适合做完整授权系统

例如:

  • 根据数据库里复杂角色表做细粒度权限判断
  • 每个请求都去查多张表再决定能不能进
  • 在这一层承担最终数据安全责任

2. 不适合慢请求

Proxy 跑在请求前面,如果这里变慢,整站入口都会被拖慢。

3. 不适合当唯一安全防线

只要接口、Server Action、Server Component 还能直接访问数据,就必须在这些地方再校验一次。

真正安全的检查应该放在哪里

1. Data Access Layer

官方文档很强调 DAL,也就是数据访问层。

例如:

import { cache } from 'react';
import { verifySession } from './session';

export const getCurrentUser = cache(async () => {
const session = await verifySession();

if (!session?.userId) {
return null;
}

return db.user.findById(session.userId);
});

再比如:

export async function getProjectById(projectId: string) {
const user = await getCurrentUser();

if (!user) {
throw new Error('unauthorized');
}

const project = await db.project.findById(projectId);

if (project.ownerId !== user.id) {
throw new Error('forbidden');
}

return project;
}

这层才是真正决定“能不能拿到数据”的地方。

2. Server Components

页面本身如果要读敏感数据,也可以直接在服务端组件里做校验。

import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/dal';

export default async function DashboardPage() {
const user = await getCurrentUser();

if (!user) {
redirect('/login');
}

return <div>Welcome {user.name}</div>;
}

这适合页面级保护。

3. Server Actions

写操作一定要再做一次鉴权和授权。

'use server';

import { getCurrentUser } from '@/lib/auth/dal';

export async function deletePost(postId: string) {
const user = await getCurrentUser();

if (!user) {
throw new Error('unauthorized');
}

const post = await db.post.findById(postId);

if (post.authorId !== user.id) {
throw new Error('forbidden');
}

await db.post.delete(postId);
}

这里不能因为页面前面已经有 proxy 就省略校验。

4. Route Handlers

如果接口会被客户端、第三方系统、小程序、移动端调用,那权限检查更应该留在 Route Handler 里。

import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/dal';

export async function GET() {
const user = await getCurrentUser();

if (!user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

const invoices = await db.invoice.findManyByUser(user.id);
return NextResponse.json(invoices);
}

一个比较稳的权限设计思路

可以把权限判断分成两类:

1. 乐观检查

依赖 cookie/session 做快速判断。

适合:

  • 页面跳转
  • 是否显示某个入口
  • 登录态是否存在

2. 安全检查

依赖数据库、DAL、真实资源归属关系做最终判断。

适合:

  • 查看敏感数据
  • 修改资源
  • 删除资源
  • 执行管理操作

这也是官方文档里 optimistic checks 和 secure checks 的区别。

常见模式:登录页和受保护页分流

一个常见需求是:

  • /login/signup 是公开页
  • /dashboard/settings 是受保护页

这时 proxy.ts 很适合做:

  • 未登录访问受保护页,跳去 /login
  • 已登录访问 /login,跳回 /dashboard

但如果 /dashboard/billing 还涉及企业角色、组织权限、计费管理员判断,这些细分权限不应该只放在 proxy.ts

和认证库怎么配合

官方文档也明确建议,实际项目通常优先考虑成熟认证库,而不是全手写。

适合引入认证库的场景:

  • OAuth 登录
  • MFA
  • 邮箱验证码
  • 用户管理流程完整
  • 多 provider 支持

而 Next 自身更像是提供:

  • 页面和服务端边界
  • cookie / headers / redirect / Route Handler / Server Action / Proxy 这些执行点

也就是说:

  • “怎么登录”通常交给认证库
  • “登录后在哪检查权限”则要结合 Next 的运行模型去设计

常见误区

1. 把 proxy.ts 当成唯一防线

这是最常见也最危险的误区。

2. 把所有权限判断散落在页面组件里

短期可行,长期会让权限逻辑越来越难统一。

3. 只有认证,没有授权

“用户登录了”不等于“用户可以访问所有资源”。

4. 只做页面跳转保护,不做数据保护

即使页面入口被拦了,接口、Server Action、DAL 也仍然必须防守。

推荐落地顺序

如果准备在 Next 项目里补鉴权体系,可以按这个顺序搭:

  1. 先选认证方案和 session 模型
  2. 再抽一层 lib/auth/*
  3. proxy.ts 里做最轻的一层路由拦截
  4. 在 DAL、Server Components、Route Handlers、Server Actions 里补最终授权检查
  5. 最后再补角色体系、资源归属、DTO 裁剪等细节

推荐继续往下看

  1. Server Actions
  2. 缓存与重验证
  3. 升级检查清单

参考资料