第 2.5 章 2.5.4-2.5.5:JWT 双 Token 与声明式权限守卫

第 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),
  };
}

这段有两个值得记的点:

  1. include 嵌套查询 :Prisma 的 include 可以一次把 用户 → 角色 → 权限 整条链拉出来,
    相当于 JPA 的 @ManyToMany + JOIN FETCH。一次查询,不用手动写 join。
  2. 用 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 的注册顺序 = 执行顺序,这一点极其关键:

  1. JwtAuthGuard 先跑:验证有没有登录,把用户信息挂到 request.user
  2. PermissionsGuard 后跑: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
    }
  }
}

它只做三件事:

  1. 有 @Public() 标记 → 放行
  2. 没有 Bearer Token → 401
  3. 验签通过 → 把载荷挂到 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 过期怎么"无感"续上、越权怎么拦。


系列文章导航

相关推荐
老板一杯拿铁1 小时前
Java 八股文面试详解:从基础到进阶的全面梳理
java·面试
嘟哩DuliDuli1 小时前
AI创作工作区的项目上下文与多模态任务边界
android·人工智能·安全·ai·软件工程
bug菌1 小时前
一个周末就想把短链服务推到 10 万 QPS,真能靠加 Redis 做到吗?
java·redis·go
撸串-研究生1 小时前
springboot福利院管理系统73979
java·spring boot·后端·福利院管理系统
阿Q的AI笔记1 小时前
13.7个百分点、6天16.7万星、99.9百分位会话超45分钟:Harness Engineering完全指南
人工智能·深度学习
chien0111 小时前
算力效率的边界:3 个有价值观点 + 4 类遗留问题(AI Infra 技术解读)
人工智能
叶子丶苏1 小时前
VibeCoding实战三个AI小项目-VibeCoding入门笔记02
大数据·人工智能·笔记
不是万能的豆学长1 小时前
AI时代程序员职业前景重塑逻辑
人工智能·ai
土生土长的IU1 小时前
今日论题-AI革命对互联网的影响
大数据·人工智能