大家好,欢迎来到“全栈权限保卫战”的现场。我是你们的资深向导。
今天我们不聊那些Hello World的入门教程,我们要聊的是真正能让产品经理拍桌子、让安全专家挠头、让开发团队在深夜两点的办公室里互相怒吼的核心技术:全栈权限控制。
想象一下,你开了一家披萨店(这就是你的Web应用)。披萨是代码,食客是用户。如果食客只付了“单人餐”的钱,你绝对不能给他端上“帝王至尊豪华至尊版”(那是管理员才能吃的东西)。如果食徒试图从后厨偷走奶酪(试图调用后端删除数据的接口),后厨的门必须锁死。
这就是我们今天要讲的故事:NestJS 后端守卫与 React 前端路由的“像素级”同步。
第一部分:后端的“看门人”——NestJS 守卫的艺术
在 NestJS 里,Guard(守卫)是什么?它是 CanActivate 接口的实现。听名字就知道,它负责“能不能激活”。它站在路由控制器之前,手里拿着一张通行证(通常是 Token),问:“你有资格来这儿吗?”
1. 基础篇:JWT 守卫
最常见的需求就是“你得登录我才知道你是谁”。这通常由 @nestjs/jwt 配合 Passport 来实现。
但如果我们想写得高级一点,不仅仅是验证 Token 存不存在,我们还要验证 Token 里的内容。
// auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
// 这里是关键:父类的 canActivate 默认只检查 token 格式是否正确
// 我们可以在这里拦截,做一些额外的“门神”工作
return super.canActivate(context);
}
}
高级玩法: 当你使用 Passport 的 Strategy 解析 Token 时,我们其实已经拿到了用户信息。但 NestJS 的默认行为通常只负责验证,不负责细粒度的权限。
2. 进阶篇:角色守卫与反射器
让我们引入传说中的 Reflector(反射器)。它就像是一个透视眼,允许守卫从装饰器中读取元数据。这就是解耦的艺术。
首先,我们需要告诉路由:“嘿,这里需要管理员权限”。
// permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
现在,我们在 Controller 上使用它:
// users.controller.ts
import { Controller, Get, UseGuards, Post } from '@nestjs/common';
import { Roles } from './permissions.decorator';
import { RolesGuard } from './roles.guard'; // 我们马上会写这个
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard) // 防火墙:先验证身份,再验证角色
export class UsersController {
@Get()
findAll() {
return "获取用户列表";
}
@Post()
@Roles('admin') // 只有 admin 能进这个门
create() {
return "创建用户";
}
}
接下来,是那个看起来有点像魔术的 RolesGuard:
// roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './permissions.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. 从装饰器中提取元数据
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// 如果没设置角色限制,那谁都行(或者你可以设置默认为 public)
if (!requiredRoles) {
return true;
}
// 2. 从请求中获取用户(这里假设 JWT Strategy 解析后存入了 request.user)
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException("你没身份证,滚粗!");
}
// 3. 验证角色
// 注意:这里比较的是字符串数组。更高级的可以用 Set 或者自定义枚举
const hasRole = requiredRoles.some((role) => user.roles?.includes(role));
if (!hasRole) {
throw new ForbiddenException("你虽然是 VIP,但你不是管理员!");
}
return true;
}
}
专家点评: 看到了吗?这就是 NestJS 的优雅之处。你不需要在每个方法里写 if (user.role !== 'admin'),你只需要一个 @Roles('admin'),然后写一次 RolesGuard,全公司所有需要权限的地方都搞定了。这就是“一次编写,到处生效”。
第二部分:前端的“安检员”——React 路由权限
好了,后端大门守住了。但用户如果直接在浏览器输入 http://myapp.com/admin/dashboard 呢?那我们就尴尬了,虽然后端会拦截请求返回 403,但前端直接弹出一个黑色的错误页,体验极差。
我们需要在前端也装一个“安检员”,在用户进入页面之前就把不守规矩的人挡在门外。
1. 基础篇:PrivateRoute 组件
在 React Router v6 时代,我们不再使用 <Switch>,而是使用 <Routes>。
// PrivateRoute.tsx
import React, { ReactElement } from 'react';
import { Navigate } from 'react-router-dom';
// 这里假设我们有一个 Context 或 Store 存储了当前登录状态和角色
import { useAuth } from '../context/AuthContext';
interface ProtectedRouteProps {
children: ReactElement;
allowedRoles?: string[];
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, allowedRoles }) => {
const { isAuthenticated, user } = useAuth();
// 1. 如果未登录,直接踢回登录页
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// 2. 如果定义了角色限制,且用户角色不匹配
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}
// 3. 安全通过
return children;
};
export default ProtectedRoute;
2. 进阶篇:在配置文件中定义路由权限
上面的写法对于小项目够用了。但对于大项目,我们在路由配置中写逻辑会非常混乱。我们希望路由配置是“声明式”的,就像后端一样。
假设我们有一个路由配置数组:
// router.config.ts
import { RouteObject } from "react-router-dom";
export const routes: RouteObject[] = [
{
path: "/",
element: <Layout />,
children: [
{
path: "dashboard",
element: <Dashboard />,
// 这里是权限声明!
meta: { roles: ['user', 'admin'] }
},
{
path: "admin/users",
element: <UserManagement />,
meta: { roles: ['admin'] }
},
{
path: "admin/settings",
element: <Settings />,
meta: { roles: ['superadmin'] }
},
],
},
];
现在我们需要写一个高阶组件来处理这些路由。这是 React 高级编程的精髓之一——高阶组件(HOC)。
// ProtectedRouteWrapper.tsx
import React from 'react';
import { Navigate, Outlet, useLocation, useRoutes } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
// 一个辅助函数,根据路由的 meta.role 检查权限
const checkPermission = (routeMeta: any, userRole: string) => {
if (!routeMeta?.roles) return true; // 没有角色限制,放行
return routeMeta.roles.includes(userRole);
};
const ProtectedRouteWrapper = () => {
const { isAuthenticated, user } = useAuth();
const location = useLocation();
// 如果还没登录,重定向到登录页,并保留当前地址以便登录后跳回
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// 如果已登录但没有角色,或者角色不匹配
if (user && !checkPermission({ roles: user.roles }, user.role)) {
return <Navigate to="/unauthorized" replace />;
}
// 正常渲染子路由
return <Outlet />;
};
export default ProtectedRouteWrapper;
3. 应用到路由生成
最后,我们将这个包裹层应用到我们的路由配置上:
// App.tsx
import { createBrowserRouter } from "react-router-dom";
import ProtectedRouteWrapper from './ProtectedRouteWrapper';
// 引入我们的组件和配置
import Layout from './Layout';
import Dashboard from './pages/Dashboard';
import UserManagement from './pages/UserManagement';
// ... import 其他组件
const router = createBrowserRouter([
{
path: "/",
element: <ProtectedRouteWrapper />, // 1. 外层套上统一的安检员
children: [
{
path: "/",
element: <Layout />,
children: [
{ path: "dashboard", element: <Dashboard /> },
{ path: "admin/users", element: <UserManagement /> },
// 注意:这里不需要再写 ProtectedRoute 了,因为它已经被外层包起来了
],
},
],
},
// 公开路由
{
path: "/login",
element: <LoginPage />,
},
]);
专家点评: 这种模式叫做“Layout Pattern”(布局模式)。它保证了所有的路由都经过了安检,无论是直接访问还是点击菜单跳转。你再也不用担心用户绕过前端去访问后台接口了(虽然后端还是得守着)。
第三部分:像素级控制的灵魂——数据粒度与 UI 反馈
真正的“像素级”控制,不仅仅是“能进页面”和“不能进页面”。
它体现在:
- 按钮的显隐: 用户能看到“删除”按钮吗?还是只能看到“编辑”按钮?
- 数据的过滤: 用户能看到所有人的数据吗?还是只能看到自己的数据?
1. 后端:返回精确的数据
后端不应该只返回一个布尔值 canDelete,而是应该返回对象或列表。这就是 RBAC (Role-Based Access Control) 的核心。
// @Get('/posts/:id')
// @Roles('admin', 'editor') // 允许编辑者和管理员访问
async getPost(@Param('id') id: string, @Request() req) {
const post = await this.postService.findOne(id);
// 如果是管理员,返回完整数据
if (req.user.roles.includes('admin')) {
return post;
}
// 如果是普通用户,返回“阉割版”数据
return {
id: post.id,
title: post.title,
// 删掉 content 字段
// content: post.content,
isPublished: post.isPublished,
};
}
2. 前端:聪明的 UI
现在,后端返回了数据,但前端还得配合。我们有两种策略:
- 策略 A(懒加载): 前端根据用户角色动态导入组件。
- 优点: 包体积小。
- 缺点: 容易出现“按钮在,但点击没反应”的 Bug,因为组件根本没加载,点击抛出 undefined 错误。
- 策略 B(显隐控制 – 推荐): 组件已加载,但通过 CSS 或 DOM 操作控制显示。
// PostEditor.tsx
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
const PostEditor = () => {
const { user } = useAuth();
const [isDeleting, setIsDeleting] = useState(false);
return (
<div>
<h1>编辑文章</h1>
<textarea>这里是文章内容...</textarea>
<div className="actions">
<button onClick={() => setIsDeleting(true)}>删除文章</button>
</div>
{/*
像素级控制 1:如果是管理员或超级编辑,显示删除按钮
如果不是,直接在 DOM 里抹掉这个按钮,省得用户点错了
*/}
{user && (user.roles.includes('admin') || user.roles.includes('superadmin')) && (
<button
className="danger-btn"
onClick={handleDelete}
style={{ background: 'red', color: 'white' }}
>
真正的删除按钮
</button>
)}
</div>
);
};
但这还不够“像素级”。如果你的角色是 editor(编辑),你能看到 admin 才能看到的数据吗?如果后端返回了,前端也应该过滤掉。
第四部分:异步守卫与全局状态同步的“深坑”
当我们进入“像素级”的高级玩法时,会碰到一些坑。
1. 异步守卫的陷阱
在 NestJS 中,很多时候验证用户角色需要查数据库(比如查用户的部门权限)。这时候 Reflector 拿到的是同步数据,但数据库查询是异步的。canActivate 函数默认是同步的,返回 Promise 会破坏路由守卫的链式调用。
解决方案: 使用 Observable。
@Injectable()
export class RolesGuard implements CanActivate {
// 必须返回 Observable 或者 Promise
canActivate(context: ExecutionContext): Promise<boolean> | Observable<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
// 如果 user 是一个 Observable(比如从 JWT Strategy 返回的),我们需要订阅它
if (isObservable(user)) {
return user.pipe(
map((user) => {
const hasRole = requiredRoles.some((role) => user.roles?.includes(role));
if (!hasRole) throw new ForbiddenException("权限不足");
return true;
})
);
}
// 标准同步逻辑
return of(this.validateRoles(user, requiredRoles));
}
private validateRoles(user: any, roles: string[]) {
return roles.some((role) => user.roles?.includes(role));
}
}
2. 前端状态同步的噩梦
现在我们有一个“像素级”的挑战:当用户在前端修改了角色(或者管理员在前端给用户分配了新角色),前端路由和权限列表应该立即更新。
如果用户刷新页面,一切正常,因为后端验证了。
但如果用户在前端操作,比如管理员修改了用户权限,这个改动可能只存到了数据库(通过 API),或者只是内存中的状态。
专家建议:
- 使用 WebSocket 或 Server-Sent Events (SSE): 当后端权限发生变化时,实时推送给前端。前端监听到事件后,触发一次
AuthContext的更新。 - Redux/Zustand/Pinia 的持久化: 将用户角色存入
localStorage或IndexedDB。但这不安全!敏感数据不能存本地。- 折中方案: 存一个“权限列表哈希”或者“权限版本号”。每次 API 请求头带上这个版本号,后端如果版本变了就拒绝访问并返回新的权限列表。前端拿到新列表,更新 Redux,路由自动重渲染。
第五部分:实战演练——构建一个“电商后台”
让我们把所有东西串起来。假设我们有一个电商后台,我们需要控制对“订单管理”的访问。
后端:
// order.controller.ts
@Controller('orders')
export class OrdersController {
@Get()
@Roles('admin', 'finance') // 财务和管理员可以看所有订单
findAll() {
return this.orderService.findAll();
}
@Post()
@Roles('admin') // 只有管理员可以创建订单
create() {
return this.orderService.create();
}
@Post(':id/cancel')
@Roles('admin') // 只有管理员可以取消订单
cancel(@Param('id') id: string) {
return this.orderService.cancel(id);
}
}
前端路由配置:
// routes.tsx
import { lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
// 路由懒加载,减少首屏体积
const OrderList = lazy(() => import('./pages/OrderList'));
const OrderDetail = lazy(() => import('./pages/OrderDetail'));
const OrderCreate = lazy(() => import('./pages/OrderCreate'));
const ProtectedRoutes = () => {
return (
<Routes>
{/*
OrderList 允许 admin 和 finance
OrderCreate 允许 admin
*/}
<Route path="orders" element={<OrderList />} meta={{ roles: ['admin', 'finance'] }} />
<Route path="orders/create" element={<OrderCreate />} meta={{ roles: ['admin'] }} />
<Route path="orders/:id" element={<OrderDetail />} meta={{ roles: ['admin', 'finance'] }} />
</Routes>
);
};
前端逻辑:
当 OrderCreate 组件加载时,它会检查自己的 meta。
// OrderCreate.tsx
import { useAuth } from '../context/AuthContext';
const OrderCreate = () => {
const { user } = useAuth();
if (!user || !user.roles.includes('admin')) {
// 这种情况理论上不应该发生,因为 ProtectedRouteWrapper 已经拦截了
// 但这是防御性编程
return <div>Access Denied</div>;
}
return (
<div>
<h1>新建订单</h1>
<form>
<input placeholder="Product Name" />
<input type="number" placeholder="Price" />
<button type="submit">Submit</button>
</form>
</div>
);
};
第六部分:总结——这是门艺术,不是苦力活
我们讲了什么?
我们讲了 NestJS 的 Reflector 和 RolesGuard,讲了 React Router 的 Outlet 和 HOC,讲了数据粒度的控制,讲了异步处理的坑。
核心思想:
- 后端是底线: 不要信任前端传来的任何权限信息。所有的
if (role === 'admin')必须在后端代码里复写一遍。 - 前端是体验: 前端守卫是为了防止“白屏”和“403 弹窗”。通过动态路由或条件渲染,我们要让用户感到自然流畅。
- 同步是关键: 像素级控制的前提是前端和后端对“角色”的定义是一致的。
最后的忠告:
如果你在写权限系统时,发现代码里充满了 && 和 || 的逻辑判断,恭喜你,你中奖了。这说明你的架构可能过于臃肿,或者缺少了抽象层。
试着去抽象一个 PermissionService,去抽象一个 AuthContext。代码写得越像“配置”,维护起来就越像“艺术品”。
好了,今天的讲座就到这里。希望你们在未来的全栈开发中,能像给电子表格加锁一样,给你们的代码加上坚固的权限大门。别让那该死的 SQL 注入或者未授权访问毁了你们毕生的心血!
祝编码愉快,守住你的代码!