J8 · 认证与授权
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
| Spring | NestJS | 要点 |
|---|---|---|
SecurityFilterChain | 全局 guard(APP_GUARD):ThrottlerGuard → JwtAuthGuard → RolesGuard | 注册顺序就是执行顺序 |
.anyRequest().authenticated() | 全局 JwtAuthGuard | 默认所有路由都要求 token |
.requestMatchers("/auth/**").permitAll() | 路由上的 @Public() | 例外声明在处理函数旁边,而不是集中在一个列表里 |
oauth2ResourceServer().jwt()、JwtDecoder | JwtStrategy(passport-jwt) | 校验签名和过期时间,填充 request.user |
PasswordEncoder(Argon2PasswordEncoder、BCryptPasswordEncoder) | argon2.hash() / argon2.verify() | 直接调用,没有专门的 bean |
@PreAuthorize("hasRole('ADMIN')") | @Roles(Role.Admin) + RolesGuard | 静态的角色列表,没有 SpEL 表达式 |
SecurityContextHolder、@AuthenticationPrincipal | request.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),并提供执行该策略的 guardAuthGuard('jwt')。
// 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 };
}
}每个受保护的请求都会经历:
ExtractJwt.fromAuthHeaderAsBearerToken()读取Authorization: Bearer <token>。- 用
JWT_SECRET校验签名(HMAC SHA-256,HS256),再检查exp时间。 - 全部有效时调用
validate(payload),它的返回值成为request.user。 - 否则 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 仍是可以接受的选择:
// 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() 指向目标方法和控制器, 并能读取它们的元数据。
// 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()
// 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/login | AuthController 的每个方法上 |
/health、/health/live、/health/ready | HealthController 类上 |
/demo/*(非生产环境) | EventLoopDemoController 类上 |
GET /v1/auth/me 和 login 在同一个控制器里,却要求 token:元数据是按方法设置的。
参数装饰器:@CurrentUser()
// 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
// @Roles(Role.Admin) plays the part of @PreAuthorize("hasRole('ADMIN')").
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);// 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 模块
@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(领域模型)。
// 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 使用。控制器只有一条路由,仅限管理员:
@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)。
注册与登录
@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 {}@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;
}
}@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)。
POST /v1/auth/register
Content-Type: application/json
{ "email": "alice@example.com", "password": "a long enough password" }{
"id": "3f2b…",
"email": "alice@example.com",
"role": "MEMBER",
"createdAt": "2026-11-19T09:30:12.418Z"
}POST /v1/auth/login
Content-Type: application/json
{ "email": "alice@example.com", "password": "a long enough password" }{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"tokenType": "Bearer",
"expiresIn": "15m"
}解码后的 token payload(exp = iat + 900 秒):
{ "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 响应头:
{
"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 的用例必须放在最后。主端到端测试依次执行注册、 登录和带认证的调用:
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 中声明,按下面的顺序执行:
// 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 },
],- 先是
ThrottlerGuard:突发的大量请求在校验 token 之前就被拦下。 - 然后是
JwtAuthGuard:它填充request.user(或放行@Public()路由)。 - 最后是
RolesGuard:它需要request.user。如果放在JwtAuthGuard之前,它拿到的user是 undefined,所有@Roles()路由都会返回 403。
两种拒绝情况,都经过端到端测试验证:
GET /v1/projects
x-correlation-id: test-123{
"status": 401,
"code": "UNAUTHORIZED",
"message": "Unauthorized",
"path": "/v1/projects",
"timestamp": "2026-11-19T09:35:02.611Z",
"correlationId": "test-123"
}GET /v1/users
Authorization: Bearer <MEMBER 用户的 token>{
"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 里:
// 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()。