前言
一直以来我都把 401 和 403 都统称为鉴权失败,但实际上二者还是有区别的,一般 401 被称为 身份验证失败,即 不存在 Token 或者 Token 失效。403 被称为 鉴权失败,即 该 Token 所代表的用户没有该功能的权限
在前面我们做了登录和注册功能,只是做了身份验证,那么接下来就来处理 鉴权
RBAC
RBAC(Role Based Access Control) 即基于角色的权限控制:
graph LR
用户1 --> 角色1 --> 权限1
角色1 --> 权限2
用户关联角色,角色关联权限 的 多对多 的表关系。
如此我们就需要设计 3 个主表:user、role、permission,再然后需要设计 2 个关联表:user_role_relation,role_permission_relation。
// user.entity.ts
@Entity()
export class User {
// ...省略 user 的其他字段
@ManyToMany(() => Role, (role) => role.users)
@JoinTable({
name: 'user_role_relation',
// joinColumn 对应当前 user表,所以里面的name是 user_id
// 即: user_role_relation 表里的 user_id 是与 User 表里的 id 关联
joinColumn: {
name: 'user_id',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'role_id',
referencedColumnName: 'id',
},
})
roles: Role[]
}
// role.entity.ts
@Entity()
export class Role {
// ...省略 role 的其他字段
@ManyToMany(() => User, (user) => user.roles)
users: User[]
@ManyToMany(() => Permission, (permission) => permission.roles)
@JoinTable({
name: 'role_permission_relation',
joinColumn: {
name: 'role_id',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'permission_id',
referencedColumnName: 'id',
},
})
permissions: Permission[]
}
// permission.entity.ts
@Entity()
export class Role {
// ...省略 permission 的其他字段
@ManyToMany(() => Role, (role) => role.permissions)
roles: Role[]
}
上面三个是主表,并且我在 user 和 role 里定义了 2 个关联表。
全局守卫-身份验证
在登录注册的文章中,我使用了 @UseGuards(LoginGuard) 来对某个接口 Handler 做身份验证。
// user.controller.ts
@Get('/list')
@UseGuards(LoginGuard) // 路由守卫,身份验证
findAll() {
return this.userService.findAll()
}
但实际上,我们的身份验证是大部分接口都需要的,如果给每个接口上都加上这个装饰器,则会显得代码臃肿多余。所以,我们一般会将这种守卫设为全局守卫
// app.module.ts
@Module({
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: LoginGuard,
},
{
provide: APP_GUARD,
useClass: RoleGuard,
},
],
})
export class AppModule {}
给予 APP_GUARD 这个 token 的 provide 就意味着全局守卫,这里我定义了 2 个全局守卫,一个负责身份验证,一个负责权限验证
但如此之后,每个接口都会触发 LoginGuard 这个守卫,但是像注册、登录这种接口明显是不需要身份验证的,那么其实我们可以有 2 种方式:
-
添加白名单,白名单内的不需要身份验证
-
添加条件,只有满足这个条件才去身份验证
这里我们采用第二种方式,我们添加一个自定义装饰器:
// 该装饰器功能:给 class 或 handler 添加上 REQUIRE_LOGIN :true 这个字段
export const RequireLogin = () => SetMetadata(REQUIRE_LOGIN, true)
然后我们只需要设计成:只有存在 @RequireLogin() 的 class 或 handler 才会触发身份验证即可。
那么就只需要在我们的 LoginGuard 中使用 reflector:
@Injectable()
export class LoginGuard implements CanActivate {
@Inject()
private reflector: Reflector
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
// 如果存在 REQUIRE_LOGIN 这个元数据,则需要身份验证
// getAllAndOverride 这个方法比较有意思,获取并覆盖,有点类似 Object.assign
const requireLogin = this.reflector.getAllAndOverride(REQUIRE_LOGIN, [context.getClass(), context.getHandler()])
console.log({ requireLogin })
if (!requireLogin) {
return true
}
}
}
然后我们只需要在 controller 里的 class 或者 handler 上添加 @RequireLogin() 即可:
// 在 handler 上添加
@Get('/list')
@RequireLogin()
findAll() {
return this.userService.findAll();
}
// 在 class 上添加
@Controller('book')
@RequireLogin()
export class BookController {
constructor(private readonly bookService: BookService) {}
@Post('/create')
async create(@Body() createBookDto: CreateBookDto) {
return await this.bookService.create(createBookDto);
}
@Get('/list')
findAll() {
return this.bookService.findAll();
}
}
在 class 上添加了之后就不需要在其他每个 handler 上添加了。因为:
// 在 class 添加之后怎么样都会返回 true
const requireLogin = this.reflector.getAllAndOverride(REQUIRE_LOGIN, [context.getClass(), context.getHandler()])
当然,实际上你也可以用 @UseGuards(LoginGuard) 给 class 上添加这个守卫,最终效果也是一样的,实现方法有很,只要语义清晰,可读性高的都是好方法。
全局守卫-鉴权
我在上面还提到了一个 RoleGuard 的全局守卫,该守卫是在身份验证成功之后,用来判断用户是否有调用这个接口的权限
@Injectable()
export class RoleGuard implements CanActivate {
@Inject()
private userService: UserService
@Inject()
private reflector: Reflector
@Inject()
private redisService: RedisService
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>()
// 获取 PERMISSION_CODE 值
const code = this.reflector.get(PERMISSION_CODE, context.getHandler())
// 如果没有 code 值则说明该接口不需要权限
let isCan = !code ? true : false
if (code) {
// 如果 code 有值则先去 redis 中看看有没有命中缓存
const userId = req.userInfo.userId + ''
const list = await this.redisService.getAuth(userId)
if (list && list?.length > 0) {
console.log('定位到缓存, 使用缓存数据', list)
if (list.some((v) => v == code)) {
isCan = true
}
} else {
// 如果没有缓存,则需要去数据库中查询,并存储到 redis 缓存中
console.log('未查找到缓存数据,生成缓存中.....')
const roles = await this.userService.findRolesByIds(req.userInfo.roles)
const permissionList = roles.map((v) => v.permissions?.map((v) => v.name))
const roleSet = new Set(permissionList.flat(1))
if (roleSet.has(code)) {
isCan = true
}
this.redisService.setAuth(userId, [...roleSet])
}
}
if (isCan == false) {
throw new ForbiddenException('您没有该权限')
}
return isCan
}
}
首先我们可以看到上面有 PERMISSION_CODE ,这个也是我写了一个自定义的装饰器:
export const SetAuthCode = (code: string) => SetMetadata(PERMISSION_CODE, code)
然后在接口 handler 上添加:
@Post('/create')
@SetAuthCode('新增书籍')
async create(@Body() createBookDto: CreateBookDto) {
return await this.bookService.create(createBookDto);
}
这里我添加的是名为: 新增书籍 ,在真正项目中这个值应该为一个 英文字母 的值,比如 BOOK_CREATE 这里用中文更清晰明了,所以简单表达了。
思路和身份验证的类似,不过为了性能方面的考虑,我们还需要在鉴权这里调用 redis 缓存。