第 2.5 章 2.5.4-2.5.5:JWT 双 Token 与声明式权限守卫
课程专题 :《AI Agents 开发实践》
章节 :第 2.5 章 RBAC 用户权限系统
小节 :2.5.4 认证、2.5.5 鉴权与业务模块
作者 :一个从 Java 转型 AI 全栈的开发者
适合人群:Java 开发者,想搞懂 JWT 与 Spring Security 注释式鉴权
1. 认证 vs 鉴权:先把两个词分清
这一篇讲后端最核心的两个词,它们经常被混用:
| 词 | 英文 | 回答的问题 | 本章对应 |
|---|---|---|---|
| 认证 | Authentication | 你是谁? | 登录、签发 Token |
| 鉴权 | Authorization | 你能做什么? | Guard 校验权限码 |
顺序不能反:先确认身份(401 拦截),再判断权限(403 拦截)。
本章的两个状态码也一一对应:
- 401 Unauthorized:没登录 / Token 无效 / Token 过期 → "我不知道你是谁"
- 403 Forbidden:登录了,但没这个权限 → "我知道你是谁,但你不能干这个"
这两者的区别,很多系统的 bug 就出在这------该返回 403 的地方返回了 401 ,
前端拿到 401 就以为"登录过期了",于是疯狂刷新 Token,越刷越乱。
第 2.5 章严格遵守:没登录 = 401,没权限 = 403。
2. 启动入口:三个护栏
看 services/user-system/src/main.ts:
typescript
// 必须是第一行 import:先加载 .env,后续模块(jwt.constants 等)才能读到环境变量
import 'dotenv/config';
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// fail-fast:JWT 密钥缺失或仍是占位值时拒绝启动。
for (const key of ['JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET']) {
const value = process.env[key];
if (!value || value.length < 32 || value.includes('dev-')) {
throw new Error(
`环境变量 ${key} 未配置或为占位值(需 ≥32 位高熵随机串,如 openssl rand -hex 32)`,
);
}
}
const app = await NestFactory.create(AppModule);
// 允许跨域:admin-web(3001 端口)浏览器端要调用本服务
app.enableCors();
// 全局入参校验管道:
// whitelist ------ 剔除 DTO 里没声明的字段(防注入多余字段)
// transform ------ 按 DTO 类型自动转换
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
// 全局路由前缀:所有接口都变成 /api/xxx
app.setGlobalPrefix('api');
await app.listen(4002);
console.log('User-system API running on http://localhost:4002/api');
}
bootstrap();
三个护栏,每个都值得单独说:
2.1 import 'dotenv/config' 必须是第一行
因为 jwt.constants.ts 在模块加载时 就读取了 process.env.JWT_ACCESS_SECRET。
如果 .env 加载晚了,读到的就是 undefined(这个坑后面第 06 篇会细讲)。
Java 对照 :相当于
application.properties的加载时机------Spring Boot 保证配置在任何 Bean 创建前就绪,Node 需要你手动保证 import 顺序。
2.2 fail-fast:密钥不合规就拒绝启动
typescript
if (!value || value.length < 32 || value.includes('dev-')) {
throw new Error(...);
}
这三行是安全审查之后加的(第 06 篇会讲这次审查)。核心思想:
宁可服务起不来,也不能用可预测的弱密钥签发超管 Token。
用 openssl rand -hex 32 生成 32 字节随机串填进 .env。长度和随机性双重要求。
2.3 ValidationPipe({ whitelist: true })
whitelist: true 会自动剔除 DTO 里没声明的字段 。
这是防"参数注入"的第一道闸------比如请求体里塞了个 isSuperAdmin: true,
而 DTO 里没这个字段,它会被静默丢掉(这个机制在第 06 篇的安全审查里立了大功)。
Java 对照 :
@Valid+@RequestBody的绑定校验,但多了一层"未知字段丢弃"。
3. JWT 双 Token:为什么是两个
3.1 为什么不用"一个 Token 管到底"
如果用单个长有效期 Token(比如 7 天),有两个问题:
- 一旦泄露,7 天内都能用,风险窗口太长
- 想让它变短?那用户就得频繁重新登录,体验极差
双 Token 解决了这个矛盾:
| Token | 有效期 | 作用 | 存放 |
|---|---|---|---|
| accessToken | 2 小时 | 每次业务请求携带,验证身份 | 内存 / localStorage |
| refreshToken | 7 天 | 只在续签时用,换新的 accessToken | 内存 / localStorage |
用户登录
↓
拿到 access(2h) + refresh(7d)
↓
正常请求带 access
↓
2 小时后 access 过期 → 请求被 401
↓
前端用 refresh 悄悄换一套新的 → 重放刚才的请求
↓
用户完全无感知(这就是"无感续签")
↓
7 天后 refresh 也过期 → 真正需要重新登录
3.2 两个关键设计:独立密钥 + type 字段
typescript
// services/user-system/src/auth/jwt.constants.ts
// 安全审查修复(2026-10-13):不再提供代码级 fallback------
// 以前 `?? 'dev-access-secret'` 看似方便,实则是"静默降级":
// 漏配环境变量时服务照常启动,攻击者可用公开的默认值自签任意超管 Token。
export const jwtConstants = {
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
accessExpiresIn: '2h',
refreshExpiresIn: '7d',
} as const;
typescript
// services/user-system/src/auth/jwt.types.ts
/** accessToken / refreshToken 的载荷 */
export interface JwtPayload {
sub: number; // 用户 id(JWT 标准字段 sub = subject)
username: string;
isSuperAdmin: boolean; // 冗余进 token,避免每次解析都查库判断
type?: 'access' | 'refresh'; // token 类型:refresh 校验时强制要求是 refresh,防止混用
}
设计一:access 和 refresh 用不同的密钥。
这样即使 accessToken 的密钥泄漏(accessToken 在请求头里来回传,暴露面更大),
攻击者也签不出 refreshToken。
设计二:payload 里带 type 字段。
刷新时强制校验 type === 'refresh',防止拿 accessToken 去当 refreshToken 用("混用攻击")。
isSuperAdmin 也冗余进了 Token:这样权限守卫判断超管时不用查库,直接读 Token 载荷。
4. 认证服务:三个安全决策
auth.service.ts 是整个后端最核心的文件。先看登录:
typescript
/** 登录:校验账密 → 签发双 Token */
async login(dto: LoginDto) {
// 1. 查用户(用户名唯一)
const user = await this.prisma.user.findUnique({
where: { username: dto.username },
});
// 2. 安全决策 1:无论"用户不存在"还是"密码错误",返回同一句话
if (!user) {
throw new UnauthorizedException('用户名或密码错误');
}
// 3. bcrypt 比对:数据库存的是哈希,绝不能明文比较
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordOk) {
throw new UnauthorizedException('用户名或密码错误');
}
// 4. 禁用用户不允许登录
if (user.status === 'DISABLED') {
throw new UnauthorizedException('账号已被禁用,请联系管理员');
}
// 5. 签发双 Token
return this.issueTokens(user.id, user.username, user.isSuperAdmin);
}
安全决策 1:错误信息不泄露"用户存在性"
注意第 2 步和第 3 步------用户不存在和密码错误,返回的是同一句话。
如果分开提示("用户不存在" vs "密码错误"),攻击者就能用这个接口
枚举出系统里有哪些用户名(这叫"用户名枚举")。统一提示,堵掉这个信息泄漏。
Java 对照 :Spring Security 的
BadCredentialsException也是统一话术,同一个思路。
安全决策 2:refresh 必须验"类型"
typescript
/** 刷新令牌:用 refreshToken 换新的双 Token */
async refresh(refreshToken: string) {
let payload: JwtPayload;
try {
// 安全决策 2:必须用 refresh 专用密钥验签
payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: jwtConstants.refreshSecret,
});
} catch {
throw new UnauthorizedException('刷新令牌无效或已过期');
}
// 防"混用攻击":拿 accessToken 来刷 → 直接拒绝
if (payload.type !== 'refresh') {
throw new UnauthorizedException('令牌类型错误');
}
// 安全决策 3:重新查库------用户被禁用/删除后,旧 refreshToken 也失效
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user || user.status === 'DISABLED') {
throw new UnauthorizedException('账号不可用');
}
return this.issueTokens(user.id, user.username, user.isSuperAdmin);
}
安全决策 3:刷新时重新查库
这是禁用功能能生效的关键。设想:
- 用户登录拿到 7 天的 refreshToken
- 管理员把他禁用了
- 如果没有这一步,他靠 refreshToken 还能续签 7 天
所以刷新时必须重新查库确认用户状态 。这是一个"以数据库为准"的兜底。
(accessToken 有效期只有 2 小时,2 小时后就会 401 触发续签,届时被拦下。)
签发 Token
typescript
/** 签发 access + refresh 双 Token(login 和 refresh 共用) */
private issueTokens(userId: number, username: string, isSuperAdmin: boolean) {
const basePayload: JwtPayload = { sub: userId, username, isSuperAdmin };
const accessToken = this.jwtService.sign(
{ ...basePayload, type: 'access' },
{ secret: jwtConstants.accessSecret, expiresIn: jwtConstants.accessExpiresIn },
);
const refreshToken = this.jwtService.sign(
{ ...basePayload, type: 'refresh' },
{ secret: jwtConstants.refreshSecret, expiresIn: jwtConstants.refreshExpiresIn },
);
return { accessToken, refreshToken };
}
profile:把权限"打包"给前端
登录之后,前端还调了一个 GET /auth/profile。它返回用户信息 + 菜单 + 权限码:
typescript
/** 当前用户信息:基础信息 + 菜单 + 权限编码(前端渲染菜单/按钮全靠它) */
async getProfile(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: {
role: { include: { permissions: { include: { permission: true } } } },
},
},
},
});
if (!user) {
throw new UnauthorizedException('用户不存在');
}
// 收集该用户所有角色下的权限,去重
const permissionSet = new Map<number, { code: string; name: string; type: string }>();
for (const ur of user.roles) {
for (const rp of ur.role.permissions) {
permissionSet.set(rp.permission.id, {
code: rp.permission.code,
name: rp.permission.name,
type: rp.permission.type,
});
}
}
const permissions = Array.from(permissionSet.values());
return {
user: {
id: user.id,
username: user.username,
nickname: user.nickname,
isSuperAdmin: user.isSuperAdmin,
status: user.status,
},
// 菜单 = 权限类型为 MENU 的部分(前端据此渲染侧边栏)
menus: permissions.filter((p) => p.type === 'MENU'),
// 全部权限编码(前端按钮显隐 + 后端接口鉴权共用一套 code)
permissions: permissions.map((p) => p.code),
};
}
这段有两个值得记的点:
include嵌套查询 :Prisma 的include可以一次把用户 → 角色 → 权限整条链拉出来,
相当于 JPA 的@ManyToMany+JOIN FETCH。一次查询,不用手动写 join。- 用
Map去重 :同一个权限可能来自多个角色(比如user和editor都有system:user:list),
用权限id做 key 塞进Map,天然去重。
顺带说个设计事实:超管
admin不绑任何角色 ,所以它的menus和permissions是空数组 。这不是 bug------超管靠
isSuperAdmin标记旁路,前端hasPermission对超管恒true,所以超管照样能看到全部菜单。第 05 篇会讲这个"旁路"。
认证 Controller 很简单:
typescript
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Public()
@Post('refresh')
refresh(@Body('refreshToken') refreshToken: string) {
if (!refreshToken) {
throw new UnauthorizedException('缺少 refreshToken');
}
return this.authService.refresh(refreshToken);
}
@Get('profile') // 没有 @Public() → 默认被全局守卫保护
profile(@Req() req: Request & { user: JwtPayload }) {
return this.authService.getProfile(req.user.sub);
}
}
登录和刷新是公开接口 ,打 @Public() 标记;profile 需要登录,所以不打。
5. 鉴权核心:两个全局守卫
现在进入 RBAC 的灵魂。NestJS 的"守卫"(Guard)决定一个请求能不能进入 Controller。
5.1 全局注册:一次注册,全站生效
typescript
// services/user-system/src/app.module.ts
// APP_GUARD 注册全局守卫------这是 NestJS 的关键机制:
// 不用在每个 Controller 上挂 @UseGuards,注册即全站生效
// 顺序即执行顺序:先 JwtAuthGuard(登录)→ 再 PermissionsGuard(权限)
@Module({
imports: [PrismaModule, AuthModule, UsersModule, RolesModule, PermissionsModule],
controllers: [HealthController],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 全局 Guard 1:登录校验
{ provide: APP_GUARD, useClass: PermissionsGuard }, // 全局 Guard 2:权限校验
JwtService,
],
})
export class AppModule {}
APP_GUARD 的注册顺序 = 执行顺序,这一点极其关键:
JwtAuthGuard先跑:验证有没有登录,把用户信息挂到request.userPermissionsGuard后跑:request.user已经就绪,才能判断权限
如果顺序反了,PermissionsGuard 拿到的是空的 request.user,直接崩。
Java 对照 :这相当于 Spring Security 的
FilterChain------一个过滤器链按顺序执行,每个过滤器决定"放行到下一个"还是"拦下来返回 401/403"。
APP_GUARD就是 NestJS 版的"全局过滤器链注册"。
5.2 JwtAuthGuard:只做认证
typescript
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Reflector 沿"方法 → 类"的顺序找 @Public() 标记(方法级优先于类级)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true; // 公开接口,直接放行
}
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('缺少访问令牌'); // → 401
}
const token = authHeader.slice(7); // 去掉 "Bearer "
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: jwtConstants.accessSecret,
});
(request as Request & { user: JwtPayload }).user = payload; // 挂到 request.user
return true;
} catch {
throw new UnauthorizedException('访问令牌无效或已过期'); // → 401
}
}
}
它只做三件事:
- 有
@Public()标记 → 放行 - 没有
Bearer Token→ 401 - 验签通过 → 把载荷挂到
request.user,放行
它不是安全边界本身,它只负责"你是谁"。
5.3 @Public() 装饰器:元数据的小魔法
typescript
// services/user-system/src/common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** 用法:在 Controller 方法或整个类上标注 @Public() */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Public() 做的事情朴素到极致:往路由的元数据里塞一个 { isPublic: true } 。
Guard 里的 Reflector 负责把这个元数据读出来。
为什么 key 要定义成常量 IS_PUBLIC_KEY? 因为装饰器写入和 Guard 读取用的是同一个 key,
写成常量两边共享,防止一处写 'isPublic'、另一处写 'public' 的拼写事故。
Java 对照 :这就像自定义注解(
@interface)+HandlerInterceptor里读注解。
SetMetadata≈ 注解,Reflector≈ 反射读取注解。
5.4 PermissionsGuard:RBAC 的心脏
typescript
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. 读取接口声明的权限(方法级优先于类级)
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
// 没声明权限的接口,只要求登录即可访问
if (!required || required.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<Request & { user: JwtPayload }>();
const user = request.user;
// 2. 超管旁路(JwtAuthGuard 已把载荷挂到 request.user)
if (user?.isSuperAdmin) {
return true;
}
// 3. 普通用户:查库汇总该用户所有角色下的权限 code
const userWithPermissions = await this.prisma.user.findUnique({
where: { id: user?.sub },
select: {
status: true,
roles: {
select: {
role: {
select: { permissions: { select: { permission: { select: { code: true } } } } },
},
},
},
},
});
// 用户在 token 有效期内被删除 → 拒绝
if (!userWithPermissions || userWithPermissions.status === 'DISABLED') {
throw new ForbiddenException('账号不可用');
}
const ownedCodes = new Set<string>();
for (const ur of userWithPermissions.roles) {
for (const rp of ur.role.permissions) {
ownedCodes.add(rp.permission.code);
}
}
// 4. 逐一核对:缺少任何一个声明权限 → 403
const missing = required.filter((code) => !ownedCodes.has(code));
if (missing.length > 0) {
throw new ForbiddenException(`无权访问,缺少权限:${missing.join(', ')}`);
}
return true;
}
}
四步逻辑,一条主线:
| 步骤 | 判断 | 结果 |
|---|---|---|
| 1 | 接口没声明权限 | 只要求登录,放行 |
| 2 | isSuperAdmin |
超管旁路,放行(不查库) |
| 3 | 查库汇总用户权限码 | 得到 ownedCodes 集合 |
| 4 | 声明权限 ⊆ 拥有权限? | 是 → 放行;否 → 403 |
注意第 4 步的错误信息里带上了"缺少哪些权限"(missing.join(', ')),
排查问题时一眼就能看出差哪个码,非常实用。
为什么是 ForbiddenException(403)而不是 UnauthorizedException(401)?
正如第 1 节强调的------你已经登录了,只是没权限,这是 403 的语义。
再回头说超管旁路(第 2 步):
typescript
if (user?.isSuperAdmin) {
return true; // 超管直接放行,连库都不查
}
超管不做权限核对,直接放行。因为 isSuperAdmin 冗余地放在 JWT 载荷里,
这一步完全不需要访问数据库------性能上是白赚的。
6. 业务接口:声明式鉴权
有了 PermissionsGuard,业务接口只需在方法上写一句 @RequirePermissions(...):
typescript
// services/user-system/src/users/users.controller.ts
// 每个接口都声明了所需权限(RBAC 的"声明式鉴权")
// Spring 对照:@PreAuthorize("hasAuthority('system:user:list')")
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@RequirePermissions('system:user:list')
@Get()
list(@Query() dto: QueryUsersDto) {
return this.usersService.findPage(dto);
}
@RequirePermissions('system:user:detail')
@Get(':id')
detail(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
@RequirePermissions('system:user:create')
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@RequirePermissions('system:user:update')
@Patch(':id/status')
updateStatus(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStatusDto) {
return this.usersService.updateStatus(id, dto.status);
}
@RequirePermissions('system:user:assign-roles')
@Patch(':id/roles')
assignRoles(@Param('id', ParseIntPipe) id: number, @Body() dto: AssignRolesDto) {
return this.usersService.assignRoles(id, dto);
}
}
这就是"声明式鉴权" :接口只声明"我需要哪个权限码",
至于谁来判断、怎么判断、判断不通过怎么办,全部交给 Guard。
业务代码里看不到一行 if (hasPermission)。这种"关注点分离"是这套设计的最大价值:
- 想加权限 → 加一行装饰器
- 改鉴权逻辑 → 只改 Guard 一处,全站生效
Java 对比 :
@RequirePermissions('system:user:list')对照
@PreAuthorize("hasAuthority('system:user:list')"),几乎一字不差。就连"权限码字符串"的写法都一致。
差别在于:Spring Security 的
@PreAuthorize依赖 SpEL 表达式,NestJS 的
@RequirePermissions只是往元数据塞数组 + Guard 读出来------更直白,更易读。
角色和权限的接口同理:
typescript
@Controller('roles')
export class RolesController {
@RequirePermissions('system:role:list')
@Get()
list() { return this.rolesService.findAll(); }
@RequirePermissions('system:role:create')
@Post()
create(@Body() dto: CreateRoleDto) { return this.rolesService.create(dto); }
@RequirePermissions('system:role:assign-permissions')
@Patch(':id/permissions')
assignPermissions(
@Param('id', ParseIntPipe) id: number,
@Body() dto: AssignPermissionsDto,
) { return this.rolesService.assignPermissions(id, dto); }
}
permissions 模块只提供一个只读列表(前端分配权限时的候选来源):
typescript
@Controller('permissions')
export class PermissionsController {
@RequirePermissions('system:permission:list')
@Get()
list() { return this.permissionsService.findAll(); }
}
7. 两个业务细节:防超管自锁 + 事务覆盖式写入
7.1 不给超管禁用自己
typescript
// users.service.ts
async updateStatus(id: number, status: 'ACTIVE' | 'DISABLED') {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`用户 ${id} 不存在`);
}
if (user.isSuperAdmin) {
throw new BadRequestException('不允许禁用超级管理员');
}
return this.prisma.user.update({
where: { id },
data: { status },
select: { id: true, username: true, status: true },
});
}
为什么要拦这一下? 因为如果允许禁用超管,就可能出现**"把人锁在系统外面"**的局面:
所有超管都被禁用 → 没人能再进来分配权限了 → 系统死锁。
这是所有"权限系统"都要考虑的经典问题:防止把最后一个管理员踢出门 。
所以后端直接 400 拒绝,前端还会把这个开关锁死(见第 05 篇)。
7.2 事务 + 覆盖式写入
分配角色 / 分配权限都是**"先清空,再全量写入"**的覆盖式操作,放在一个事务里:
typescript
// users.service.ts ------ 给用户分配角色
await this.prisma.$transaction([
this.prisma.userRole.deleteMany({ where: { userId: id } }),
this.prisma.userRole.createMany({
data: dto.roleIds.map((roleId) => ({ userId: id, roleId })),
}),
]);
typescript
// roles.service.ts ------ 给角色分配权限
await this.prisma.$transaction([
this.prisma.rolePermission.deleteMany({ where: { roleId } }),
...(dto.permissionIds.length > 0
? [this.prisma.rolePermission.createMany({
data: dto.permissionIds.map((permissionId) => ({ roleId, permissionId })),
})]
: []),
]);
- 事务(
$transaction) 保证"清空"和"写入"要么全成功、要么全失败,
不会出现"清空了但没写进去"的中间态。 Java 对照:@Transactional,一模一样。 - 覆盖式语义 :传的是"最终想要的完整列表",不是"要新增的增量"。
这样前端逻辑简单(勾选最终状态即可),后端也不会出现"删一个加一个"的错乱。 - 注意
rolePermission允许传空数组 (清空权限),而assign-roles的 DTO 里
用@ArrayMinSize(1)要求至少一个角色------不能让用户一个角色都没有(那就彻底没权限了)。
8. 验证:三种身份走一遍
铺好后端,用 curl / PowerShell 验证三种身份:
powershell
# ① 未登录访问业务接口 → 401
Invoke-RestMethod -Uri http://localhost:4002/api/users
# → 401 缺少访问令牌
# ② 登录 testuser
$r = Invoke-RestMethod -Method Post -Uri http://localhost:4002/api/auth/login `
-ContentType 'application/json' -Body '{"username":"testuser","password":"User@123456"}'
$h = @{ Authorization = "Bearer $($r.accessToken)" }
# ③ testuser 有 system:user:list → 200
Invoke-RestMethod -Uri http://localhost:4002/api/users -Headers $h
# ④ testuser 没有 system:user:create → 403(注意不是 401!)
Invoke-RestMethod -Method Post -Uri http://localhost:4002/api/users -Headers $h `
-ContentType 'application/json' -Body '{"username":"demo1","password":"Demo@123456"}'
# → 403 无权访问,缺少权限:system:user:create
# ⑤ 超管 admin → 全部 200
第 ④ 步是本章最关键的验收点 :登录了、但没权限 → 403 。
这一条验证通过,说明"认证"和"鉴权"两条链路都通了。
9. 本篇小结
| 环节 | 关键设计 |
|---|---|
| 认证 | 双 Token(access 2h / refresh 7d)、独立密钥、type 防混用、刷新重查库 |
| 守卫 | APP_GUARD 全局注册,顺序 = 执行顺序(先认证后鉴权) |
| 声明 | @Public() 放行公开接口;@RequirePermissions() 声明接口权限 |
| 语义 | 没登录 = 401,没权限 = 403 |
| 旁路 | 超管读 JWT 载荷直接放行,不查库 |
| 安全细节 | 统一错误话术防枚举;禁超管防自锁;事务覆盖式写入 |
后端防线建好了。下一篇转向前端:怎么登录、Token 过期怎么"无感"续上、越权怎么拦。
系列文章导航
- 上一篇:02 2.5.3 Prisma 落地:建模、迁移与种子数据(./02-2.5.3 Prisma 落地:建模、迁移与种子数据.md)
- 下一篇:04 2.5.6 前端登录、Token 无感续签与路由守卫(./04-2.5.6 前端登录、Token 无感续签与路由守卫.md)
- 回到索引 :第 2.5 章博客索引