claude-skills 项目 NestJS 认证实战:基于 Passport 的 JWT 鉴权、守卫机制与 RBAC 角色权限控制完整实现
【免费下载链接】claude-skills67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer.项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills
NestJS 以其模块化架构、依赖注入与装饰器体系成为企业级 TypeScript 后端的首选框架,而认证与授权正是其防护体系的核心。本文以 claude-skills 仓库中 nestjs-expert 技能 的认证参考文档为主干,从 JWT 策略、认证守卫、角色守卫、认证服务到模块装配与全局守卫,系统讲解一套可直接落地到生产项目的完整鉴权方案。读完本文,你将掌握 passport-jwt 的完整接线方式、基于 Reflector 元数据驱动的@Public()与@Roles()装饰器设计、bcrypt 密码哈希处理,以及如何通过APP_GUARD将守卫提升为应用级全局防线。
一、认证体系概览:从技能文档到实现蓝图
在 claude-skills 项目中,nestjs-expert 被定位为"企业级可扩展 TypeScript 后端应用"的专项技能,其核心工作流包括需求分析、结构设计、实现、安全加固(Guards、Validation Pipes、Authentication)与验证五个阶段。也就是说,认证并非可选项,而是该技能工作流的第四步强制环节。
认证参考文档 references/authentication.md 给出的实现骨架由六个相互咬合的组件构成,它们共同回答了鉴权领域的三个核心问题:
| 问题 | 组件 | 作用 |
|---|---|---|
| 你是谁? | JwtStrategy+AuthService | 验证令牌有效性、完成登录与注册 |
| 允许访问吗? | JwtAuthGuard | 保护路由,校验请求中的 JWT |
| 允许做什么? | RolesGuard | 基于角色的访问控制(RBAC) |
下面逐一剖析每个组件的实现原理、关键配置与仓库内可交叉验证的配套实践。
二、JWT Strategy:令牌验证的入口
JWT 验证的职责被封装在JwtStrategy中,它继承自@nestjs/passport的PassportStrategy,底层由passport-jwt驱动:
// jwt.strategy.ts import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private config: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: config.get('JWT_SECRET'), }); } async validate(payload: { sub: string; email: string; role: string }) { return { userId: payload.sub, email: payload.email, role: payload.role }; } }三个关键配置项的语义如下:
jwtFromRequest:令牌提取方式。ExtractJwt.fromAuthHeaderAsBearerToken()要求客户端在Authorization请求头中以Bearer <token>形式携带令牌,这是 REST API 最常见的做法。passport-jwt还提供fromUrlQueryParameter、fromAuthHeaderAsApiKey、fromExtractors(支持多来源依次尝试)等提取器,可按需切换。ignoreExpiration: false:强制校验exp声明。若设为true会接受已过期的令牌,在生产环境应始终保持为false。这是从仓库安全规范中反复强调的令牌时效纪律,详见下文第五节。secretOrKey:签名校验密钥。文档刻意通过ConfigService从环境配置读取JWT_SECRET,而非硬编码在源码中——这一点与 nestjs-expert 技能约束里"MUST NOT hardcode credentials"的规则严格对应。
validate(payload)是 Passport 约定俗成的回调:token 通过签名与过期校验后,payload 会被传入这里做二次加工(例如查询数据库补充用户状态),返回值会被挂载到request.user上,供后续守卫、控制器直接消费。这里将扁平化的sub(subject,标准 JWT 声明,代表用户唯一 ID)显式映射为更语义化的userId字段,同时透传email与role,为 RBAC 提供了数据基础。
三、JwtAuthGuard 与 @Public():路由级防护与白名单
JwtStrategy只负责"验证",真正决定"是否放行"的是守卫(Guard)。文档中的JwtAuthGuard继承了@nestjs/passport的AuthGuard('jwt'),其中'jwt'字符串需与前面PassportStrategy(Strategy)注册的默认策略名对齐:
// jwt-auth.guard.ts import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Reflector } from '@nestjs/core'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { constructor(private reflector: Reflector) { super(); } canActivate(context: ExecutionContext) { const isPublic = this.reflector.get<boolean>('isPublic', context.getHandler()); if (isPublic) return true; return super.canActivate(context); } handleRequest(err: any, user: any) { if (err || !user) { throw err || new UnauthorizedException('Invalid token'); } return user; } } // Public decorator export const Public = () => SetMetadata('isPublic', true);这段代码蕴含了两个值得深入的设计:
其一,基于 Reflector 的白名单机制。@Public()装饰器通过SetMetadata('isPublic', true)把元数据写入路由处理器。canActivate中使用Reflector.get在方法层级读取该元数据,命中则直接放行(返回true),否则走父类标准的 Passport 认证流程。这样登录、注册、健康检查等"天然公开"的端点只需标注@Public(),不必各自绕过守卫,尤其适合守卫被全局挂载的场景(见第六节)。
其二,handleRequest的异常收敛。Passport 在验证失败时可能抛出一系列底层错误(如TokenExpiredError、JsonWebTokenError),handleRequest将它们统一收敛为框架层的UnauthorizedException,并兜底抛出'Invalid token'消息。这保证了客户端收到的永远是结构化的 401 响应,而不会泄漏内部实现细节——这正是 SKILL.md 中"MUST NOT expose internal stack traces in responses"的落地体现。
使用方式:
@UseGuards(JwtAuthGuard) @Get('profile') getProfile(@Request() req) { return this.usersService.findById(req.user.userId); } @Public() @Post('login') login(@Body() dto: LoginDto) { /* 无需令牌即可访问 */ }四、RolesGuard 与 @Roles():基于角色的访问控制
认证通过只代表"登录者身份有效",并不代表"有权限执行操作"。RolesGuard解决的就是授权问题,其核心是读取@Roles()写入的元数据并与当前用户的role做比对:
// roles.decorator.ts export const Roles = (...roles: string[]) => SetMetadata('roles', roles); // roles.guard.ts @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const roles = this.reflector.getAllAndOverride<string[]>('roles', [ context.getHandler(), context.getClass(), ]); if (!roles) return true; const { user } = context.switchToHttp().getRequest(); return roles.includes(user.role); } } // Usage @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @Get('admin') adminEndpoint() {}这里有一处细节值得特别注意:getAllAndOverride同时扫描方法(getHandler())与类(getClass())两个层级的元数据,且方法级声明优先覆盖类级声明。这意味着可以把@Roles('admin')放在整个控制器类上作为默认策略,再对个别公开或放宽的端点做方法级覆盖,实现"类级默认 + 方法级例外"的灵活权限模型。
此外,RolesGuard内部通过context.switchToHttp().getRequest()读取user——这正是上一节JwtStrategy.validate挂载到request.user上的对象。因此两个守卫必须按@UseGuards(JwtAuthGuard, RolesGuard)的顺序执行:JwtAuthGuard 先完成身份解析,RolesGuard 才能拿到user.role。若@Roles()未标注任何角色(元数据为空数组时if (!roles) return true),守卫自动放行,避免"忘了写角色反而 403"的陷阱。
需要留意的是,user.role的取值来自令牌中的role声明(见JwtStrategy.validate与AuthService.login的 payload 构造)。如果角色发生变化,旧令牌在过期前仍携带旧角色,因此在角色体系严格的生产环境,建议缩短令牌时效或引入权限实时校验。
五、AuthService:登录、注册与密码安全
AuthService承载认证领域的业务逻辑,是唯一接触密码明文的位置:
@Injectable() export class AuthService { constructor( private usersService: UsersService, private jwtService: JwtService, ) {} async validateUser(email: string, password: string): Promise<User | null> { const user = await this.usersService.findByEmail(email); if (user && await bcrypt.compare(password, user.password)) { return user; } return null; } async login(user: User) { const payload = { sub: user.id, email: user.email, role: user.role }; return { access_token: this.jwtService.sign(payload), refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }), }; } async register(dto: CreateUserDto) { const hashedPassword = await bcrypt.hash(dto.password, 10); return this.usersService.create({ ...dto, password: hashedPassword }); } }三个方法分别对应认证的经典三段式流程:
validateUser(凭证校验):先按邮箱定位用户,再用bcrypt.compare做哈希比对。绝不比对明文密码——数据库只存哈希,即使泄露也无法反推出原文。login(签发令牌):payload 只放sub、email、role三个非敏感声明(避免把密码等机密塞进令牌——JWT 是签名而非加密的,任何人解码即可读取内容),签发双令牌:access_token:有效期由模块配置决定(本文示例为 15 分钟),用于业务接口鉴权;refresh_token:单独设置7d有效期,用于 access_token 过期后的无感续签。
register(注册):用bcrypt.hash(password, 10)以10 轮盐值开销生成哈希后入库。盐值轮数(cost factor)越高破解成本越大,可参考仓库中 secure-code-guardian 的认证规范 进一步提高至 12 轮,并强制密码满足大小写字母、数字与特殊字符的复杂度要求。
仓库的认证文档同样强调令牌时效纪律:访问令牌 15 分钟、刷新令牌 7 天、JWT 声明中sub存用户 ID、exp记录过期时间、iat记录签发时间。这套双令牌 + 短时效策略可在令牌泄露时将风险窗口压缩到分钟级,而刷新令牌因生命周期较长,需配合令牌撤销(revocation)列表或轮换机制使用。
六、AuthModule 装配:依赖注入与配置注入
所有组件最终通过AuthModule完成 DI 接线:
@Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ secret: config.get('JWT_SECRET'), signOptions: { expiresIn: '15m' }, }), }), UsersModule, ], providers: [AuthService, JwtStrategy], exports: [AuthService], }) export class AuthModule {}配置要点:
PassportModule.register({ defaultStrategy: 'jwt' }):声明默认策略,使AuthGuard('jwt')可以简写为AuthGuard(),也确保 JwtStrategy 被全局注册到 Passport 的可用策略集中。JwtModule.registerAsync+useFactory:与第一节中JwtStrategy读取JWT_SECRET的方式一脉相承——密钥与默认过期时间(15 分钟)全部来自ConfigService,源码中零硬编码。registerAsync保证了模块初始化顺序(先注入 ConfigService,再据此配置 JwtModule)。imports: [UsersModule]:AuthService需要调用UsersService.findByEmail与create,因此 UsersModule 必须对外exports其服务——这正是 services-di.md 中"export only when other modules need this service"的典型用例。exports: [AuthService]:允许其他模块(如需要获取当前用户信息的模块)注入AuthService,但刻意不导出JwtStrategy,将其保持为模块内部实现细节。
七、全局守卫:用 APP_GUARD 把防线提升到应用级
将守卫逐个标注在每个控制器上容易遗漏。文档最后给出了全局化方案——利用APP_GUARD常量把守卫注册为全局守卫:
// app.module.ts @Module({ providers: [ { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, ], }) export class AppModule {}两点实践提示:
- 守卫顺序即
providers数组顺序:JwtAuthGuard在前,先完成身份认证;RolesGuard在后,再做角色授权。若颠倒顺序,RolesGuard 会因拿不到request.user而失败。 - 全局化后白名单机制成为刚需:
@Public()装饰器(第三节)正是为此设计——登录、注册、/health等端点必须显式标注@Public()才能绕过全局 JwtAuthGuard,形成"默认全禁、显式放行"的安全基线。
此外,仓库中的 controllers-routing.md 还展示了局部守卫的写法:在控制器类上统一加@UseGuards(JwtAuthGuard),再对个别端点用@Public()放行,两种策略可依据"大部分公开还是大部分私密"灵活选择。
八、与 DTO 验证、测试体系的联动
认证不是孤岛,它必须与仓库配套的校验与测试文档协同工作,构成完整闭环:
入口参数校验。dtos-validation.md 给出了LoginDto的标准写法——通过PickType(CreateUserDto, ['email', 'password'])从注册 DTO 中精确挑选字段,配合@IsEmail()、@MinLength(8)等 class-validator 装饰器在进入AuthService之前拦截非法输入。全局ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })会剥离未知字段并对参数做类型转换,防止注入攻击与类型绕过。
鉴权流程的测试验证。testing-patterns.md 中的 E2E 测试给出了与本文完全一致的鉴权闭环验证方式:
// 先通过登录接口获取令牌 const response = await request(app.getHttpServer()) .post('/auth/login') .send({ email: 'test@test.com', password: 'password' }); authToken = response.body.access_token; // 携带 Bearer 令牌访问受保护接口 return request(app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${authToken}`) .send({ email: 'new@test.com', password: 'Test1234', name: 'New' }) .expect(201);这套"登录换令牌 → 携带令牌访问"的测试模式,同时验证了AuthService.login的令牌签发与JwtAuthGuard的令牌校验两条链路。单元测试层面则推荐用Test.createTestingModule配合jest.Mocked<UsersService>对AuthService的validateUser(成功、密码错误、用户不存在三种分支)逐一断言。
九、安全加固清单:从可用到可靠
结合仓库内 secure-code-guardian 的认证规范,将本文方案再提升一档的生产级加固项包括:
| 维度 | 基础方案(本文) | 加固方案 |
|---|---|---|
| 密码哈希 | bcrypt.hash(password, 10) | 提高 salt rounds 至 12,并强制 12 位以上复杂密码 |
| 令牌时效 | access 15m / refresh 7d | 增加令牌类型声明(type: 'access' \| 'refresh'),refresh 令牌仅用于换发、不可访问业务接口 |
| 登录防护 | 无 | 登录失败计数 + 锁定(如 5 次失败锁定 15 分钟) |
| 密钥管理 | ConfigService.get('JWT_SECRET') | 环境变量 + 密钥轮换机制,绝不入库进源码 |
同时应保持 SKILL.md 的硬性约束:不在响应中暴露密码或内部堆栈、不把any类型散落进守卫代码、服务层一律抛出类型化 HTTP 异常(UnauthorizedException、ForbiddenException等),并推荐与 secure-code-guardian、test-master 技能组合使用以获得安全审计与测试策略的双重保障。
十、速查表
| 组件 | 用途 |
|---|---|
JwtStrategy | 校验 JWT 签名与过期时间,解析request.user |
JwtAuthGuard | 保护路由,未携带有效令牌返回 401 |
RolesGuard | 基于角色的访问控制(RBAC) |
@Public() | 跳过认证(配合全局守卫使用) |
@Roles('admin') | 声明端点所需角色,与RolesGuard协作 |
@UseGuards() | 在控制器/方法上应用守卫 |
AuthService | 注册、登录、签发双令牌、凭证校验 |
APP_GUARD | 将守卫注册为应用级全局守卫 |
这套基于 JWT + Passport + Guard 的组合方案,配合@Public()白名单与@Roles()元数据驱动设计,足以支撑从单体 API 到微服务架构的统一鉴权需求。如需在既有 Express 项目上迁移到 NestJS 认证体系,可直接参考仓库的 Express 迁移指南,其中包含从 Express 手动jsonwebtoken中间件逐步改造为 NestJS Guard 的完整对照实现。
【免费下载链接】claude-skills67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer.项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考