Skip to content

J8 · 认证与授权 ​

第 8 天 · 11 月 19 日周四 · 交付物:Users、登录、受保护的路由

Spring Security 自带一条预先配置好的过滤器链。Nest 只提供积木:用 Passport 和 @nestjs/jwt 校验 token,用 guard(守卫) 决定请求能否通过,用元数据标记路由。TaskFlow 把它们组装成默认安全的形态:除了标记为 @Public() 的路由,所有路由都要求 有效的 JWT;部分路由只对管理员开放;成员只能看到自己参与的项目。

目标 ​

  • 用 @nestjs/passport、passport-jwt 和 @nestjs/jwt 校验 JWT。
  • 用 argon2id(或 bcrypt)存储密码,并在校验登录时不泄露信息。
  • 用全局 guard 保护所有路由,用 @Public() 开放例外。
  • 用参数装饰器 @CurrentUser() 读取当前用户。
  • 用 @Roles() 和一个通过 Reflector 读取元数据的 RolesGuard 实现 RBAC。
  • 在 TaskFlow 中交付:users 模块、POST /v1/auth/register 和 POST /v1/auth/login、全局 JWT guard、项目只对成员可见。

Spring → NestJS ​

SpringNestJS要点
SecurityFilterChain全局 guard(APP_GUARD):ThrottlerGuard → JwtAuthGuard → RolesGuard注册顺序就是执行顺序
.anyRequest().authenticated()全局 JwtAuthGuard默认所有路由都要求 token
.requestMatchers("/auth/**").permitAll()路由上的 @Public()例外声明在处理函数旁边,而不是集中在一个列表里
oauth2ResourceServer().jwt()、JwtDecoderJwtStrategy(passport-jwt)校验签名和过期时间,填充 request.user
PasswordEncoder(Argon2PasswordEncoder、BCryptPasswordEncoder)argon2.hash() / argon2.verify()直接调用,没有专门的 bean
@PreAuthorize("hasRole('ADMIN')")@Roles(Role.Admin) + RolesGuard静态的角色列表,没有 SpEL 表达式
SecurityContextHolder、@AuthenticationPrincipalrequest.user、@CurrentUser()没有 ThreadLocal:用户通过参数传递
UserDetailsService由 AuthService.login() 调用的 UsersService.findByEmail()普通的应用代码

原理 ​

Passport、passport-jwt 与 @nestjs/jwt ​

三个包,三种职责:

  • @nestjs/jwt 提供 JwtService,用来签发(signAsync)和校验 token。
  • passport-jwt 是一个 Passport 策略:从请求中读取 token,校验签名和过期时间。
  • @nestjs/passport 把策略变成 Nest provider(PassportStrategy),并提供执行该策略的 guard AuthGuard('jwt')。
ts
// Checks the signature and expiry of "Authorization: Bearer <token>". What
// validate() returns becomes request.user (see @CurrentUser()).
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(@Inject(jwtConfig.KEY) config: JwtConfig) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.secret,
    });
  }

  validate(payload: JwtPayload): AuthUser {
    // The role is read from the token: a role change applies at the next login.
    return { id: payload.sub, email: payload.email, role: payload.role };
  }
}

每个受保护的请求都会经历:

  1. ExtractJwt.fromAuthHeaderAsBearerToken() 读取 Authorization: Bearer <token>。
  2. 用 JWT_SECRET 校验签名(HMAC SHA-256,HS256),再检查 exp 时间。
  3. 全部有效时调用 validate(payload),它的返回值成为 request.user。
  4. 否则 guard 抛出 UnauthorizedException,全局过滤器返回 401。

JWT 是签名的,不是加密的:任何人都能解码它的 payload(sub、email、role、iat、exp),那只是 base64url 编码的 JSON。不要往里放任何机密信息。它也是无状态的:服务器不保存会话,所以 token 在过期前一直有效。因此有效期要短 (TaskFlow 是 15 分钟)。

Passport 不是必需的:官方文档也给出了直接调用 JwtService.verifyAsync() 的 guard。当多种策略并存时(JWT、OAuth2、API key), Passport 才显出价值。

密码哈希:argon2 或 bcrypt ​

TaskFlow 使用 argon2 包。argon2.hash(password) 生成一个带随机盐的 argon2id 哈希,编码成一个以 $argon2id$ 开头的字符串, 里面包含算法、参数和盐。argon2.verify(hash, password) 会读回这些信息。单元测试检查了前缀,并确认哈希里不包含密码本身。

Argon2id 故意设计得又慢又耗内存,让针对被盗数据库的暴力破解代价高昂。bcrypt 仍是可以接受的选择:

ts
// TaskFlow 之外的示例:用 bcrypt 做同样的事(npm i bcrypt)
import * as bcrypt from 'bcrypt';

const hash = await bcrypt.hash(password, 12); // 12 = 成本因子(2^12 次迭代)
const ok = await bcrypt.compare(password, hash);

bcrypt 会忽略超过 72 字节的部分:前 72 字节相同的两个长密码,哈希也相同。argon2 没有这个限制。两种情况下都要在 DTO 里限制 长度(RegisterDto 和 LoginDto 里的 @MaxLength(128)),以免超长输入带来过高的哈希开销。

在 Spring 里,这对应 PasswordEncoder.encode() / matches()。Spring 会在哈希前加上算法前缀({bcrypt}...),以便日后更换算法; argon2 的格式本身就带有算法和参数。

全局 guard 与 AuthGuard ​

guard 实现 CanActivate.canActivate(context),返回 true(请求继续)、false(Nest 返回 403,消息为 Forbidden resource), 或者抛出异常(401、429 等)。它在中间件之后、拦截器和管道之前执行(执行顺序)。 和中间件不同,它拿到的是 ExecutionContext:context.getHandler() 和 context.getClass() 指向目标方法和控制器, 并能读取它们的元数据。

ts
// Registered globally (APP_GUARD): every route needs a valid token unless it
// is marked @Public(). Secure by default, like an anyRequest().authenticated().
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    return isPublic ? true : super.canActivate(context);
  }
}

JwtAuthGuard 继承 AuthGuard('jwt');'jwt' 是 passport-jwt 注册策略时用的名字。它只加了一条规则:如果路由带有 @Public(),就直接放行,不校验 token。

通过 APP_GUARD 声明后,它作用于所有路由,包括以后新增的路由。这相当于 anyRequest().authenticated()。如果反过来 (每个控制器各自 @UseGuards()),漏掉一个就会留下一条开放的路由;而这里漏掉一个 @Public(),最多只是返回 401。

和 SecurityFilterChain 的类比在这里不成立:guard 只保护 Nest 的处理函数。在控制器之外提供的内容,比如 SwaggerModule 挂载在 /docs 上的 Swagger 界面,不会经过 guard(Swagger)。

公开路由:@Public() ​

ts
// Metadata read by the global JwtAuthGuard: this route needs no token.
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

SetMetadata(键, 值) 给被装饰的方法或类附加一条元数据。JwtAuthGuard 用 reflector.getAllAndOverride(IS_PUBLIC_KEY, [handler, class]) 读取它:方法上的值优先,否则取类上的值。TaskFlow 的公开路由:

路由@Public() 在哪里
POST /v1/auth/register、POST /v1/auth/loginAuthController 的每个方法上
/health、/health/live、/health/readyHealthController 类上
/demo/*(非生产环境)EventLoopDemoController 类上

GET /v1/auth/me 和 login 在同一个控制器里,却要求 token:元数据是按方法设置的。

参数装饰器:@CurrentUser() ​

ts
// A custom parameter decorator: @CurrentUser() user: AuthUser
// instead of SecurityContextHolder.getContext().getAuthentication().
export const CurrentUser = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): AuthUser =>
    ctx.switchToHttp().getRequest<Request & { user: AuthUser }>().user,
);

createParamDecorator() 创建一个类似 @Body() 或 @Param() 的装饰器。Nest 在构造处理函数参数时执行它,并传入 ExecutionContext。这里它返回 request.user,也就是 JwtStrategy.validate() 生成的 AuthUser 对象。

这就是 SecurityContextHolder 或 @AuthenticationPrincipal 的作用。类比在机制上不成立:Spring 把认证信息放在 ThreadLocal 里,任何 service 都能读到。Node 用一个线程服务所有请求,所以没有隐式上下文:控制器读取用户,再把它 作为参数传给 service(projects.findOne(id, user))。这样 service 更容易测试,对用户的依赖也直接体现在方法签名上。 也可以在 service 里注入 REQUEST,但这会让它变成请求作用域,代价见 J3。

RBAC:@Roles() 与 RolesGuard ​

ts
// @Roles(Role.Admin) plays the part of @PreAuthorize("hasRole('ADMIN')").
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
ts
// Runs after JwtAuthGuard, so request.user is already set. Returning false
// makes Nest answer 403 Forbidden.
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!roles?.length) return true;
    const user = context.switchToHttp().getRequest<{ user?: AuthUser }>().user;
    return user !== undefined && roles.includes(user.role);
  }
}

@Roles(Role.Admin) 本身什么也不做:它只是设置一条元数据。RolesGuard 在 JwtAuthGuard 之后全局注册,通过 Reflector 读取这条元数据。没有 @Roles() 时直接放行;有的话就比对 request.user 的角色。返回 false 即得到 403。

和 @PreAuthorize("hasRole('ADMIN')") 的区别:

  • @PreAuthorize 执行 SpEL 表达式,可以读取参数(#projectId)。@Roles() 是固定列表;依赖具体资源的规则放在 service 里 (成员)。
  • Spring 的方法安全可作用于任何 bean。guard 只作用于路由处理函数。
  • 没有 ROLE_ 前缀:取值就是 Role 枚举的值(ADMIN、MEMBER)。
  • 角色来自 token,而不是数据库:角色变更要到下次登录才生效。在 token 过期之前,旧角色仍被接受,最多 15 分钟。

TaskFlow 案例 ​

users 模块 ​

ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 254, unique: true })
  email: string;

  // Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
  // @ApiHideProperty keeps it out of the OpenAPI schema as well.
  @Exclude()
  @ApiHideProperty()
  @Column({ name: 'password_hash', type: 'varchar' })
  passwordHash: string;

  @Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
  role: Role;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}
  • email 在数据库中唯一;UsersService 在写入和读取时都会把它转成小写。
  • passwordHash 带两个装饰器,各管一件事。@Exclude() 在运行时生效:全局的 ClassSerializerInterceptor 会把这个字段从所有 JSON 响应中去掉,但只对 User 的实例有效(原始实体)。@ApiHideProperty() 作用于 文档:这个字段不会出现在 /docs 的 OpenAPI schema 里(J11)。两者不能互相替代。
  • role 是 PostgreSQL 枚举(user_role),默认值为 MEMBER(领域模型)。
ts
// No component scan: a provider is only visible to modules that import this
// one, and only because it is listed in `exports`.
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

UsersService(创建、findByEmail、findById、findAll)导出给 AuthModule 使用。控制器只有一条路由,仅限管理员:

ts
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.Admin) // every route of this controller: ADMIN only, otherwise 403
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get()
  findAll() {
    return this.users.findAll();
  }
}

注册总是创建 MEMBER:RegisterDto 没有 role 字段,全局 ValidationPipe 会拒绝带这个字段的请求体(400, property role should not exist)。第一个管理员来自种子数据(npm run seed 会创建 admin@taskflow.local)。

注册与登录 ​

ts
@Module({
  imports: [
    UsersModule,
    PassportModule,
    // forRootAsync / registerAsync: the module is configured from other
    // providers (here the typed JWT config) once they exist.
    JwtModule.registerAsync({
      inject: [jwtConfig.KEY],
      useFactory: (config: JwtConfig) => ({
        secret: config.secret,
        // '15m', '1h'... checked at startup by validateEnv.
        signOptions: { expiresIn: config.expiresIn as JwtSignOptions['expiresIn'] },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
ts
@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Public()
  @Post('register')
  register(@Body() dto: RegisterDto) {
    // Returns the User entity: @Exclude() keeps passwordHash out of the JSON.
    return this.auth.register(dto);
  }

  @Public()
  @Throttle({ default: { limit: 5, ttl: 60_000 } }) // 5 attempts per minute per IP
  @Post('login')
  @HttpCode(HttpStatus.OK)
  login(@Body() dto: LoginDto) {
    return this.auth.login(dto);
  }

  @ApiBearerAuth()
  @Get('me')
  me(@CurrentUser() user: AuthUser) {
    return user;
  }
}
ts
@Injectable()
export class AuthService {
  // Verified against when the email is unknown, so both cases take as long.
  private readonly dummyHash = argon2.hash('timing-equalizer');

  constructor(
    private readonly users: UsersService,
    private readonly jwt: JwtService,
    @Inject(jwtConfig.KEY) private readonly config: JwtConfig,
  ) {}

  async register(dto: RegisterDto): Promise<User> {
    if (await this.users.findByEmail(dto.email)) {
      throw new ConflictException('Email already registered');
    }
    // argon2id with a random salt; never store or log the password itself.
    return this.users.create(dto.email, await argon2.hash(dto.password));
  }

  async login(dto: LoginDto): Promise<AccessToken> {
    const user = await this.users.findByEmail(dto.email);
    const valid = await argon2.verify(user?.passwordHash ?? (await this.dummyHash), dto.password);
    if (!user || !valid) throw new UnauthorizedException('Invalid credentials');

    const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
    return { accessToken: await this.jwt.signAsync(payload), tokenType: 'Bearer', expiresIn: this.config.expiresIn };
  }
}
  • register() 拒绝已存在的邮箱(409),然后存储 argon2id 哈希。它返回实体,借助 @Exclude(),返回结果里没有 passwordHash。
  • login() 对未知邮箱和错误密码给出相同的响应(401,Invalid credentials)。为了不让响应时间暴露账号是否存在,用户不存在时 它会去校验一个假的哈希(dummyHash)。
  • 签名的 payload 包含 sub(id)、email 和 role。expiresIn 来自带类型的配置(J7)。
http
POST /v1/auth/register
Content-Type: application/json

{ "email": "alice@example.com", "password": "a long enough password" }
json
{
  "id": "3f2b…",
  "email": "alice@example.com",
  "role": "MEMBER",
  "createdAt": "2026-11-19T09:30:12.418Z"
}
http
POST /v1/auth/login
Content-Type: application/json

{ "email": "alice@example.com", "password": "a long enough password" }
json
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
  "tokenType": "Bearer",
  "expiresIn": "15m"
}

解码后的 token payload(exp = iat + 900 秒):

json
{ "sub": "3f2b…", "email": "alice@example.com", "role": "MEMBER", "iat": 1795080612, "exp": 1795081512 }

带 Authorization: Bearer <token> 调用 GET /v1/auth/me,返回 { id, email, role }。

限制尝试次数。 @Throttle({ default: { limit: 5, ttl: 60_000 } }) 为 login 覆盖了 ThrottlerModule 的全局限制 (每分钟 100 个请求;/health 和 /demo/* 通过 @SkipThrottle() 豁免)。计数按路由和 IP 地址分别统计。一分钟内的第 6 次尝试 会收到 429,并带有 Retry-After 响应头:

json
{
  "status": 429,
  "code": "TOO_MANY_REQUESTS",
  "message": "ThrottlerException: Too Many Requests",
  "path": "/v1/auth/login",
  "timestamp": "2026-11-19T09:31:40.207Z",
  "correlationId": "c1d7e0b2-5a8f-4f3e-9b61-0e2f4d7a9c35"
}

端到端测试套件也受这个限制:前面的测试已经从同一地址登录了五次,所以测试 429 的用例必须放在最后。主端到端测试依次执行注册、 登录和带认证的调用:

ts
it('register -> login -> project -> task -> status change', async () => {
  const register = await http.post('/v1/auth/register').send({ email: 'alice@example.com', password }).expect(201);
  expect(register.body.passwordHash).toBeUndefined(); // @Exclude + ClassSerializerInterceptor
  expect(register.body.role).toBe(Role.Member);

  const login = await http.post('/v1/auth/login').send({ email: 'alice@example.com', password }).expect(200);
  const auth = { Authorization: `Bearer ${login.body.accessToken}` };

  const project = await http.post('/v1/projects').set(auth).send({ name: 'Website' }).expect(201);
  const task = await http
    .post(`/v1/projects/${project.body.id}/tasks`)
    .set(auth)
    .send({ title: 'Write the README', dueDate: '2026-11-24' })
    .expect(201);
  expect(task.body.status).toBe('TODO');

  await http.patch(`/v1/tasks/${task.body.id}/status`).set(auth).send({ status: 'DONE' }).expect(200);
  const done = await http.get(`/v1/projects/${project.body.id}/tasks?status=DONE`).set(auth).expect(200);
  expect(done.body).toHaveLength(1);
});

可选:refresh token(TaskFlow 未实现) ​

学习计划把 refresh token 列为可选项;TaskFlow 没有实现它。如果需要,原理如下:

  • 登录时,服务器返回 access token(15 分钟)和一个 refresh token:一个不透明的随机值(例如 crypto.randomBytes 生成的 32 字节),有效期为几天。
  • 服务器只存储 refresh token 的哈希(refresh_tokens 表:用户、哈希、过期时间、吊销时间),和密码的处理方式一样。
  • POST /v1/auth/refresh(@Public() 路由)校验 refresh token,吊销它,并签发一个新的 refresh token 和新的 access token(轮换)。 如果有人出示一个已被吊销的 token,很可能是 token 被盗:吊销该用户的所有会话。
  • 登出时吊销 refresh token。在浏览器里,用 HttpOnly、Secure、SameSite 的 cookie 传递它。
  • 刷新时从数据库重新读取用户:这样角色变更在 15 分钟内生效,不必等到重新登录。

全局 JWT guard 与公开路由 ​

三个 guard 都在 AppModule 中声明,按下面的顺序执行:

ts
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
  {
    provide: APP_PIPE,
    useValue: new ValidationPipe({
      whitelist: true, // strip properties without a decorator
      forbidNonWhitelisted: true, // ...or reject the request: 400
      transform: true, // plain JSON -> DTO instances, "2" -> 2
    }),
  },
  { provide: APP_GUARD, useClass: ThrottlerGuard },
  { provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
  { provide: APP_GUARD, useClass: RolesGuard },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
  { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
],
  1. 先是 ThrottlerGuard:突发的大量请求在校验 token 之前就被拦下。
  2. 然后是 JwtAuthGuard:它填充 request.user(或放行 @Public() 路由)。
  3. 最后是 RolesGuard:它需要 request.user。如果放在 JwtAuthGuard 之前,它拿到的 user 是 undefined,所有 @Roles() 路由都会返回 403。

两种拒绝情况,都经过端到端测试验证:

http
GET /v1/projects
x-correlation-id: test-123
json
{
  "status": 401,
  "code": "UNAUTHORIZED",
  "message": "Unauthorized",
  "path": "/v1/projects",
  "timestamp": "2026-11-19T09:35:02.611Z",
  "correlationId": "test-123"
}
http
GET /v1/users
Authorization: Bearer <MEMBER 用户的 token>
json
{
  "status": 403,
  "code": "FORBIDDEN",
  "message": "Forbidden resource",
  "path": "/v1/users",
  "timestamp": "2026-11-19T09:35:20.940Z",
  "correlationId": "7a0e4c1f-2b9d-4e58-8c36-d51f0a6b2e97"
}

401 的意思是“我不知道你是谁”(token 缺失、无效或过期);403 的意思是“我知道你是谁,但你没有权限”。

用户只能看到自己的项目 ​

光靠角色不够:两个成员角色相同,项目却不同。这条规则关乎数据,所以放在 service 里:

ts
// Business rules live here; the controller only maps HTTP to these calls.
@Injectable()
export class ProjectsService {
  constructor(@Inject(PROJECTS_STORE) private readonly store: ProjectsStore) {}

  create(dto: CreateProjectDto, user: AuthUser): Promise<Project> {
    return this.store.create(dto, user.id);
  }

  findAll(query: ProjectQueryDto, user: AuthUser): Promise<Page<Project>> {
    // Admins see everything, members only their own projects.
    return this.store.findPage(query, user.role === Role.Admin ? undefined : user.id);
  }

  // A non-member gets 404, not 403: the answer must not reveal that the
  // project exists. A soft-deleted project is not found either, even though
  // its membership rows are kept.
  async findOne(id: string, user: AuthUser): Promise<Project> {
    const project = await this.store.findById(id);
    const allowed = user.role === Role.Admin || (await this.store.isMember(id, user.id));
    if (!project || !allowed) throw new NotFoundException(`Project ${id} not found`);
    return project;
  }

  async update(id: string, dto: UpdateProjectDto, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.update(id, dto))!;
  }

  async remove(id: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.softDelete(id);
  }

  async archive(id: string, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.archive(id))!;
  }

  async addMember(id: string, userId: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.addMember(id, userId);
  }

  /** Used by TasksService: same rule as findOne(). */
  async assertAccess(projectId: string, user: AuthUser): Promise<void> {
    await this.findOne(projectId, user);
  }
}
  • 列表:管理员看到全部;成员只拿到自己在 project_members 中的项目(用 QueryBuilder 做连接,见 J4)。
  • 单个访问:findOne() 要求项目存在(没有被软删除)并且调用者是成员或管理员。修改、删除、归档和添加成员都会先调用它。 TasksService 通过 assertAccess() 委托给 findOne():项目下的任务遵循同样的规则,项目被删除后也一样。存在性检查很重要, 因为软删除会保留 project_members 中的行(J4);单元测试 “closes a soft-deleted project to its former members (tasks included)” 验证了这一点。
  • 创建:创建者在创建项目的同一个事务里成为成员(J5)。

非成员收到的是 404,不是 403。403 会确认项目存在,从而让人能枚举 ID。用 404 的话,无权访问的项目和不存在的项目无法区分。 端到端测试验证了这一点:第二个用户访问第一个用户的项目得到 404,自己的列表为空({ "items": [], "total": 0, ... })。

场景速查 ​

  • 除少数路由外,所有路由都要求 token → 全局 JWT guard + @Public()(全局 guard)。
  • 某条路由仅限管理员 → 在方法或类上加 @Roles(Role.Admin)(RBAC)。
  • service 需要当前用户 → 在控制器里用 @CurrentUser(),再作为参数传给 service(当前用户)。
  • 不能让用户知道某个资源存在 → 在 service 里检查并返回 404(成员)。
  • 登录要能抵御暴力破解 → 路由上加 @Throttle()、使用慢哈希、统一错误信息(注册与登录)。
  • passwordHash 绝不能泄露 → @Exclude() + ClassSerializerInterceptor,返回实例;文档方面用 @ApiHideProperty()(users)。
  • 已删除的项目要对原成员关闭 → 先检查存在性再检查成员关系,只在一个地方做(findOne())(成员)。
  • 角色变更要尽快生效 → access token 有效期短,刷新时重新读数据库(refresh token)。

陷阱 ​

JWT 密钥太弱或被共用

schema 要求 JWT_SECRET 至少 8 个字符:这是下限,不是推荐值。知道密钥的人就能签出一个 ADMIN token。生产环境要用一个很长的 随机值,每个环境各不相同,由编排平台提供,绝不纳入版本控制。

全局 guard 的顺序

APP_GUARD 按声明顺序执行。RolesGuard 放在 JwtAuthGuard 之前,会拒绝所有 @Roles() 路由(此时还没有 request.user)。

  • register 或 login 上忘了加 @Public():谁都没法注册(401)。
  • 公开路由上 request.user 是 undefined:@CurrentUser() 在那里什么也拿不到。
  • guard 返回 false 得到的是 403。要返回 401,就抛出 UnauthorizedException。
  • 返回 { ...user } 或原始查询结果:它不再是 User 实例,@Exclude() 不生效,passwordHash 会出现在响应里。
  • 只有 @Exclude() 没有 @ApiHideProperty():这个字段永远不会出现在 JSON 里,但仍会在 /docs 中被描述 (J11)。
  • “邮箱不存在”和“密码错误”给出不同的提示,就能让人枚举账号。注意 register 对已存在的邮箱返回 409:TaskFlow 接受了这个折中。
  • 在反向代理后面,req.ip 是代理的地址:所有客户端共用同一个 @Throttle() 计数器。配置 Express 的 trust proxy (在 NestExpressApplication 上调用 app.set('trust proxy', 1))。有多个实例时,throttler 的内存存储是按实例分别计数的。
  • bcrypt 会截断到 72 字节(哈希)。

练习 ​

  • [ ] 创建 users 模块:User 实体(邮箱唯一、排除 passwordHash、角色为 ADMIN / MEMBER)。
  • [ ] POST /v1/auth/register(argon2id 哈希)和 POST /v1/auth/login(JWT access token)。
  • [ ] 全局 JWT guard(APP_GUARD),在注册、登录和 /health 上加 @Public()。
  • [ ] @CurrentUser() 装饰器和 GET /v1/auth/me 路由。
  • [ ] 在 GET /v1/users 上加 @Roles(Role.Admin) + RolesGuard:MEMBER 收到 403。
  • [ ] 项目只对成员可见;非成员访问一个存在的项目收到 404。
  • [ ] 登录上加 @Throttle(),每分钟 5 次;验证第 6 次返回 429。
  • [ ] 用 curl 验证:没有 token → 401,token 过期或被篡改 → 401,注册时带 "role": "ADMIN" → 400。
  • [ ] (可选)设计带轮换和吊销的 refresh token。
  • [ ] 在 LEARNINGS.md 里记下:SecurityFilterChain ↔ 全局 guard,@PreAuthorize ↔ @Roles() + RolesGuard, SecurityContextHolder ↔ request.user / @CurrentUser()。

延伸阅读 ​