Guard 与认证
这一课你将能做到
- 实现登录接口签发 JWT,并写出可校验 Bearer Token 的
JwtAuthGuard
- 用自定义
@Roles() 装饰器配合 RolesGuard 做角色控制(仅管理员能增删题目)
- 区分 401 与 403,说出全局 Guard 加
@Public() 的放行模式
为什么要学
Guard(守卫)在请求进入 Controller 之前回答一个问题:「这个请求有资格继续吗?」判定包括是否登录、令牌是否有效、角色是否足够;不通过时框架直接返回 401/403,业务方法不会执行。
刷题平台规则:浏览做题人人可访问,增删改仅管理员,提交作答必须登录。这些判断散落在 Service 中必然重复易漏,Guard 把鉴权收敛成前置层,对应 Spring Security 过滤器链加 @PreAuthorize。
先建立图像
请求 → Guard(认证/鉴权)──失败→ 401(未认证)/ 403(无权限)
→ Pipe(校验转换)→ Controller → Service
JWT:login 拿 access_token;请求带 Bearer 头,
Guard 验签验过期并把用户挂到 request.user。
| NestJS | Spring Security |
|---|
CanActivate | 请求过滤器 / 鉴权决策器 |
@UseGuards(...) | @PreAuthorize 方法级安全 |
request.user | SecurityContextHolder 中的 Principal |
全局 Guard + @Public() | 默认拦截 + permitAll() 白名单 |
跟着做
步骤 1:安装 JWT 模块并创建 AuthModule
npm install @nestjs/jwt
nest g module auth && nest g controller auth --no-spec && nest g service auth --no-spec
步骤 2:实现登录签发 Token
src/auth/auth.service.ts:
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
export type Role = "student" | "admin";
interface UserRow {
id: number; username: string; password: string; role: Role;
}
@Injectable()
export class AuthService {
// 接库后换真实查询,密码存哈希
private users: UserRow[] = [
{ id: 1, username: "student1", password: "123456", role: "student" },
{ id: 2, username: "admin", password: "admin123", role: "admin" },
];
constructor(private readonly jwt: JwtService) {}
login(username: string, password: string) {
const user = this.users.find((u) => u.username === username && u.password === password);
if (!user) {
throw new UnauthorizedException("用户名或密码错误");
}
const payload = { sub: user.id, username: user.username, role: user.role };
return { access_token: this.jwt.sign(payload) };
}
}
src/auth/auth.controller.ts:
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("login")
login(@Body() dto: { username: string; password: string }) {
return this.auth.login(dto.username, dto.password);
}
}
@Module({
imports: [
JwtModule.register({ secret: "dev-secret-change-me", signOptions: { expiresIn: "2h" } }),
],
controllers: [AuthController],
providers: [AuthService],
exports: [JwtModule], // 供其他模块 Guard 使用
})
export class AuthModule {}
登录验证:POST /api/auth/login,body 为 {"username":"admin","password":"admin123"},应返回 { access_token }。
步骤 3:实现 JwtAuthGuard
新建 src/auth/guards/jwt-auth.guard.ts:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
export interface RequestUser {
id: number; username: string; role: "student" | "admin";
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const header: string | undefined = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedException("缺少 Bearer Token");
}
try {
const payload = this.jwt.verify<{
sub: number; username: string; role: RequestUser["role"];
}>(header.slice(7));
req.user = { id: payload.sub, username: payload.username, role: payload.role };
return true;
} catch {
throw new UnauthorizedException("Token 无效或已过期");
}
}
}
步骤 4:用 @CurrentUser 取登录用户
新建 src/auth/decorators/current-user.decorator.ts:
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): RequestUser =>
context.switchToHttp().getRequest().user,
);
改造 SubmissionsController 的提交方法(用登录用户 id 替换写死的 1001):
@Post()
@UseGuards(JwtAuthGuard)
submit(@Body() dto: SubmitAnswerDto, @CurrentUser() user: RequestUser) {
return this.submissions.submit(dto.questionId, user.id, dto.selectedIndex);
}
带 Token 提交 id=1、selectedIndex=0 返回 correct: true;无头返回 401。
步骤 5:角色控制——@Roles 加 RolesGuard
新建 src/auth/decorators/roles.decorator.ts:
export const ROLES_KEY = "roles";
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
新建 src/auth/guards/roles.guard.ts:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) {
return true; // 未声明角色要求,放行
}
const user = context.switchToHttp().getRequest().user as
| RequestUser | undefined;
if (!user) {
throw new UnauthorizedException();
}
if (!required.includes(user.role)) {
throw new ForbiddenException("权限不足,需要:" + required.join(","));
}
return true;
}
}
在 QuestionsController 的写操作上挂载:
@Delete(":id")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("admin")
@HttpCode(204)
remove(@Param("id", ParseIntPipe) id: number) {
this.questions.remove(id);
}
student1 的 Token 调创建接口得 403;admin 的 Token 得 201。
步骤 6:全局 Guard 加 @Public 放行(推荐模式)
逐个挂容易漏:默认全部接口要登录,再标记公开接口。
新建 src/auth/decorators/public.decorator.ts:
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
JwtAuthGuard 注入 Reflector,canActivate 开头加:
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getClass(),
]))
return true;
AppModule 的 providers 中全局注册两个 Guard:
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
最后给登录和公开查询接口贴 @Public(),对应 Spring Security 的 permitAll 白名单。
讲清楚要点
- 决策前置:Guard 在 Pipe 之前执行,未登录请求不消耗业务资源。
- 401 与 403 语义不同:没登录或令牌无效是 401;登录了但角色不够是 403。
- 认证与授权分层:JwtAuthGuard 解决「你是谁」,RolesGuard 解决「你能做什么」。
- Reflector 读元数据:装饰器写、Guard 读,是 Nest 扩展点的通用模式。
- 全局默认拒绝更安全:新接口默认受保护,忘挂守卫也不会裸奔。
- 密码红线:生产必须存 bcrypt/argon2 哈希,密钥放环境变量。
你可能会懵的地方
-
Guard 里能注入 Service 吗?
能。Guard 是 @Injectable,可查库做 token 黑名单,在对应模块登记即可。
-
JWT 存哪里?前端怎么带?
存浏览器内存或 httpOnly Cookie;请求放 Authorization: Bearer <token> 头。payload 只签名不加密,不放敏感信息。
-
@UseGuards 传类和传实例的区别?
传类由容器解析、可注入依赖;new MyGuard() 的实例无法注入。初学者记住一律传类。
-
多个 Guard 的执行顺序?
按 @UseGuards(A, B) 声明顺序执行,任一失败立即中断;全局 Guard 先于控制器级 Guard。
错法对照
| 错法 | 问题 | 正解 |
|---|
| Service 里读 header 判断登录 | 重复、易漏、难测试 | Guard 统一前置 |
| JWT 密钥写死源码 | 随仓库泄露 | ConfigModule + 环境变量 |
| 角色不够也返回 401 | 语义混淆 | 未认证 401、权限不足 403 |
| 把 body 格式校验写进 Guard | 职责混乱 | 格式交 Pipe,权限交 Guard |
| 全局拦截却忘给 login 加 @Public | 无法获取 Token | 登录接口显式放行 |
卡点急救
- 所有请求都 401:确认公开接口贴了
@Public();头格式 Bearer <token>(一个空格)。
- Token 解析失败:签发校验须同一 secret;检查 expiresIn 是否过期。
- RolesGuard 永远 403:核对 payload 与
@Roles("admin") 的 role 拼写一致。
面试常问
-
Guard 的执行时机与用途?
中间件之后、Pipe 之前执行,返回布尔值决定是否继续;失败映射 401/403。
-
401 和 403 的区别?
401 是未认证或凭据无效,语义「先证明你是谁」;403 是已认证但不允许访问,语义「知道你是谁,但你不能做」。
-
NestJS 怎么做方法级权限控制?
SetMetadata 写角色装饰器,注入 Reflector 的 Guard 读取所需角色与请求用户比对,经 @UseGuards 或 APP_GUARD 生效,对应 Spring 的 @PreAuthorize。
自测三问
- JWT Bearer 登录流程的三个步骤是什么?
- JwtAuthGuard 与 RolesGuard 分别回答什么问题?为什么拆成两个?
- 全局 Guard 模式下,如何让登录接口免登录访问?
小结一句话
Guard 是路由级鉴权前置层:JwtAuthGuard 管身份、RolesGuard 管权限,全局默认拒绝加 @Public 放行最稳妥。
延伸