一、先搞懂什么是NestJS守卫,为啥权限验证会不通过
很多人刚接触NestJS的时候,会觉得守卫就是个“看门的”,其实说白了就是一段提前写好的规则,在用户访问某个接口、路由之前,先检查他有没有权限,有权限就放行,没权限就直接挡回去。比如你做个后台管理系统,普通用户不能访问管理员的用户删除接口,这时候就需要守卫来拦着。但经常有人遇到,明明规则写了,守卫却没拦住,或者明明有权限却被挡,这时候就需要一步步排查问题。
这里先给个最基础的守卫示例,方便后面排查的时候对应看代码,所有示例统一用NestJS + TypeScript的技术栈:
// 权限守卫示例:检查用户是否有admin权限
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 从请求中获取用户信息(假设登录后用户信息存在request.user)
const request = context.switchToHttp().getRequest();
// 从路由元数据中获取需要的权限
const requiredPermission = this.reflector.get<string>('permission', context.getHandler());
// 检查用户权限是否匹配
return request.user?.permissions?.includes(requiredPermission);
}
}
上面这个就是最常见的权限守卫写法,先从请求里拿登录后存的用户信息,再从路由上拿需要的权限,最后做匹配。接下来的排查就围绕这个流程一步步来。
二、权限验证不通过的核心排查思路,从入口到结果一步步找
2.1 第一步:先确认守卫有没有真的生效
很多人写了守卫却没注册,或者注册的地方不对,导致守卫根本没跑,自然权限验证就等于没做,或者验证逻辑根本没触发。这一步是最基础的,却也是最容易踩的坑。
常见的注册场景有三种:全局注册、控制器注册、路由注册,不同的注册方式生效范围不一样,先确认自己的注册是否正确。 比如全局注册的写法,是在main.ts里,所有接口都会走这个守卫:
// main.ts 全局注册守卫示例
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthGuard } from './auth.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 全局注册守卫,所有路由都会经过
app.useGlobalGuards(new AuthGuard(new Reflector()));
await app.listen(3000);
}
bootstrap();
如果是给某个控制器单独注册,就写在控制器装饰器里:
// 控制器注册守卫示例
import { Controller, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
// 给整个控制器的所有路由加守卫
@Controller('user')
@UseGuards(AuthGuard)
export class UserController {}
如果是给单个路由注册,就写在路由装饰器里:
// 路由注册守卫示例
import { Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
@Controller('user')
export class UserController {
// 只给这个get接口加守卫
@Get('info')
@UseGuards(AuthGuard)
getUserInfo() {
return '用户信息';
}
}
排查的时候先看自己需要加守卫的接口,是属于哪个注册范围的,有没有漏加。比如你想给/user/delete接口加守卫,结果只给控制器加了,或者只给某个路由加了,都会导致没生效。另外如果用了全局守卫,还要注意有没有用@UseGuards装饰器覆盖,比如全局加了AuthGuard,某个路由又用@UseGuards(AnotherGuard),就会把全局的覆盖掉。
2.2 第二步:检查用户信息有没有正确传递到守卫
守卫验证权限的第一步是拿到用户的信息,比如用户的角色、权限列表,如果用户信息没传过来,或者传的不对,那验证肯定不通过。用户信息一般是登录的时候存在request对象里的,比如登录接口成功后,把用户的id、权限等存在request.user里,守卫再从这里拿。
先看登录接口怎么存用户信息的,比如用JWT登录的场景:
// 登录接口示例:登录成功后把用户信息存在request.user
import { Post, Body, Req } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UserService } from './user.service';
@Controller('auth')
export class AuthController {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
@Post('login')
async login(@Body() loginDto: any, @Req() req) {
// 验证账号密码,假设userService返回用户信息
const user = await this.userService.validateUser(loginDto.username, loginDto.password);
if (!user) {
return { code: 401, message: '账号密码错误' };
}
// 登录成功,把用户信息存在request.user
req.user = {
id: user.id,
username: user.username,
permissions: user.permissions, // 比如['user:read', 'user:delete']
};
// 生成JWT token
const token = this.jwtService.sign({ id: user.id });
return { code: 200, message: '登录成功', data: { token } };
}
}
这里要注意,登录接口如果没把用户信息存到request.user,或者存的字段名不对,比如存成了req.userInfo而不是req.user,那守卫里拿request.user就会拿到undefined,验证自然不通过。另外如果用了JWT的守卫,还要检查JWT的解析是否正确,比如token有没有过期、有没有被篡改,解析出来的用户信息有没有问题。
还有一种常见的情况,是跨模块的问题,比如用户模块在UserModule,守卫在AuthModule,两个模块没关联,导致守卫拿不到用户模块存的用户信息。这时候要检查模块的导入导出,比如UserModule要把UserService导出,AuthModule要导入UserModule,确保依赖正确。
2.3 第三步:检查路由元数据有没有正确配置
守卫里需要知道当前路由需要什么权限,这个权限一般是通过路由元数据配置的,比如用@SetMetadata装饰器,或者自定义装饰器。如果元数据配置错了,比如需要admin权限却配成了user权限,或者元数据的key不对,守卫就拿不到正确的权限要求。
先看自定义装饰器的写法,方便统一配置权限:
// 自定义权限装饰器示例
import { SetMetadata } from '@nestjs/common';
// 定义元数据的key,要和守卫里的key一致
export const PERMISSION_KEY = 'permission';
// 自定义装饰器,用来给路由配置需要的权限
export const RequirePermission = (permission: string) => SetMetadata(PERMISSION_KEY, permission);
然后在路由上用这个装饰器:
// 路由配置权限示例
import { Delete } from '@nestjs/common';
import { RequirePermission } from './permission.decorator';
@Controller('user')
export class UserController {
// 给删除接口配置需要user:delete权限
@Delete(':id')
@RequirePermission('user:delete')
deleteUser() {
return '删除用户';
}
}
这时候守卫里要拿这个元数据,就要用同一个key:
// 守卫里拿元数据的示例,key要和自定义装饰器的key一致
const requiredPermission = this.reflector.get<string>(PERMISSION_KEY, context.getHandler());
排查的时候要注意,元数据的key有没有写错,比如自定义装饰器里用的是'permission',守卫里却用了'permissions',少了个s,就会拿不到元数据,requiredPermission就会是undefined,验证不通过。另外还要注意,元数据是配置在路由上还是控制器上,如果配置在控制器上,守卫要拿控制器的元数据,而不是路由的,比如:
// 控制器配置元数据示例
@Controller('user')
@RequirePermission('user:read') // 给整个控制器配置权限
export class UserController {}
这时候守卫里拿元数据就要改:
// 拿控制器元数据的示例
const requiredPermission = this.reflector.get<string>(PERMISSION_KEY, context.getClass());
如果还是拿context.getHandler(),就会拿不到控制器的元数据,导致验证不通过。
2.4 第四步:检查权限匹配逻辑有没有问题
前面的都没问题,那就要看守卫里的匹配逻辑了,比如用户的权限列表和需要的权限是不是真的匹配,有没有大小写问题、空格问题,或者逻辑写错了。
比如用户的权限列表是['user:read', 'user:delete'],需要的权限是'user:delete',那匹配是对的。但如果用户的权限列表是['user:delete '](后面多了个空格),或者需要的权限是'User:Delete'(大小写不对),就会匹配不上。
还有一种常见的逻辑错误,是把权限的顺序搞反了,比如把用户的权限放在前面,需要的权限放在后面,或者用了includes的反向,比如:
// 错误的匹配逻辑示例
return requiredPermission.includes(request.user?.permissions);
这样写的话,requiredPermission是字符串,includes的参数是数组,肯定会返回false,验证不通过。正确的应该是用户的权限数组包含需要的权限字符串,也就是之前写的return request.user?.permissions?.includes(requiredPermission);。
另外还要注意用户权限为空的情况,比如用户没登录,request.user是undefined,或者用户的permissions是undefined,这时候要提前处理,比如返回401未登录,而不是直接返回false。比如可以加个判断:
// 优化后的匹配逻辑示例
if (!request.user) {
return false; // 未登录,直接返回不通过
}
return request.user.permissions?.includes(requiredPermission) ?? false;
如果没加这个判断,当request.user是undefined的时候,会报错Cannot read properties of undefined,导致守卫直接抛出异常,而不是返回不通过,这时候也会表现为权限验证不通过。
2.5 第五步:检查中间件、拦截器有没有干扰守卫
NestJS的请求处理顺序是:中间件 -> 守卫 -> 拦截器 -> 管道 -> 路由处理函数。如果中间件或者拦截器修改了request对象,比如把request.user改了,或者加了其他的处理,就会影响守卫的验证。
比如有个中间件,把request.user改成了null,或者把用户的权限改了,那守卫拿到的就是修改后的用户信息,验证就会不通过。排查的时候可以先把中间件和拦截器注释掉,看看守卫能不能正常工作,如果注释后正常了,那就是中间件或者拦截器的问题,再一步步排查是哪个中间件或者拦截器的问题。
比如一个错误的中间件示例:
// 错误的中间件示例:修改了request.user
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class WrongMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 错误地把request.user改成了null
req.user = null;
next();
}
}
这个中间件会把所有请求的request.user改成null,导致守卫拿不到用户信息,验证不通过。
三、权限验证不通过的应用场景、优缺点和注意事项
3.1 应用场景
权限验证不通过的场景非常多,比如后台管理系统的接口权限控制,不同角色的用户访问不同的接口;电商系统的订单权限控制,普通用户只能查看自己的订单,管理员可以查看所有订单;内容管理系统的内容权限控制,编辑只能修改自己的内容,管理员可以修改所有内容;还有微服务之间的权限控制,不同服务之间访问需要验证权限。
3.2 技术优缺点
守卫的权限验证方式,优点是逻辑清晰,统一在路由访问之前验证,不会出现已经处理了业务逻辑才发现没权限的情况;代码复用性高,守卫可以全局注册,不用每个接口都写验证逻辑;可扩展性强,可以根据需求自定义复杂的验证逻辑,比如多权限组合验证、角色继承验证等。
缺点是如果守卫的逻辑写的太复杂,会影响接口的响应速度;如果注册不当,会出现权限验证漏做的情况;如果依赖的用户信息传递错误,会导致验证逻辑出错,排查起来比较麻烦;还有如果用了全局守卫,会影响所有接口,包括不需要权限的接口,比如登录接口、注册接口,需要额外做白名单处理。
3.3 注意事项
首先,守卫的注册要清晰,明确哪些接口需要守卫,哪些不需要,避免漏加或者多加;其次,用户信息的传递要统一,比如都存在request.user里,字段名要规范,避免大小写、空格等问题;然后,元数据的配置要准确,key要和守卫里的key一致,控制器和路由的元数据要区分开;另外,权限匹配逻辑要严谨,要处理边界情况,比如未登录、权限为空等;最后,要做白名单处理,比如登录接口、注册接口不需要权限验证,要在守卫里跳过这些接口,或者在注册守卫的时候排除这些接口。
比如白名单处理的示例:
// 守卫里的白名单处理示例
const whiteList = ['/auth/login', '/auth/register'];
const request = context.switchToHttp().getRequest();
// 如果请求路径在白名单里,直接放行
if (whiteList.includes(request.path)) {
return true;
}
// 其他逻辑...
四、文章总结
NestJS守卫权限验证不通过的排查,其实就是沿着请求的流程一步步找问题,从守卫有没有生效,到用户信息有没有传对,再到元数据有没有配置对,匹配逻辑有没有错,最后再检查有没有其他中间件、拦截器干扰。只要按照这个顺序一步步排查,大部分问题都能找到。
平时写代码的时候,要注意规范,比如统一用户信息的存储位置、统一元数据的key、写好注释,这样出问题的时候排查起来会快很多。另外,写守卫的时候要做好边界处理,比如未登录、权限为空等情况,避免出现报错。还要多测试,比如写个测试用例,分别测试有权限、没权限、未登录的情况,确保守卫的逻辑正确。
Comments