Nest Guard Authentication Authorization

守卫 (Guards)

守卫是一个类,标注了 @Injectable 装饰器,且实现 CanActivate 接口,这个接口返回当前请求是否应被允许的布尔值 (同步或异步 Promise Observable),返回值用于判断下一步操作应该如何执行,为 true 请求继续,为 false 请求被拒绝。

守卫抛出的任何异常都会被异常过滤器处理,返回 fasle 将默认抛出 ForbiddenException, 可指定抛出其他错误(如 throw new UnauthorizedException())。

它的执行时机是客户端发起一个 HTTP 请求到路由处理器之间,决定一个请求是否应被路由处理器接收,通常也被称为鉴权(含授权)。要注意是守卫执行时机在中间件(middleware)之后,和拦截器(interceptor)及管道(pipe)之前。

在 Express 应用中通常是中间件处理,但存有个问题是调用了 next() 后不知道接下来哪个处理器执行,而守卫可以访问到 ExecutionContext 实例,便知道下一步要执行的逻辑。

授权守卫

授权守卫的一个使用场景是:特定的路由只能被已鉴权的用户访问(比如请求的头部含 token)

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return customValidateRequest(request); // 自定义的验证请求的函数
  }
}

执行上下文 (Execution context)

ExecutionContext 继承自 ArgumentsHost,除此之外,添加了若干个 helper 方法用于提供关于当前执行进程的额外信息。 通过 getRequest() 方法可以访问到当前的请求对象。

基于角色的授权(Role-based authentication)

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

绑定守卫(Binding guards)

与管道 pipes 、异常过滤器 exception filters 一样,守卫的使用范围可以是控制器、方法或全局。

@Controller('cats')
@UseGuards(RolesGuard)
export class CatController {}

以上的 @UseGuards(RolesGuard),传一个类意味着由框架实例化,并启用依赖注入。另外一种形式是传入实例 @UseGuards(new RolesGuard())

全局守卫 useGlobalGuards 应用于整个应用。在依赖注入方面,由于不属于任何一个模块,不能执行依赖项插入,解决办法如下

import { APP_GUARD } from '@nestjs/core';
@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    }
  ]
});
export class AppModule {}

单个处理器设置角色 (Setting roles per handler)

Nest 通过 @SetMetadata() 装饰器给路由处理器提供用户自定义的元数据(custom metadata)。

// roles.decorator.ts
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

// cats.controller.ts
@Post()
@Roles('admin') // roles 此处为 ['admin']
async create(@Body() createCatDto: CreateCatDto) {
  this.catService.create(createCatDto);
}

RolesGuard 结合的例子

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(prviate reflector: Reflector) {} // reflector helper 访问路由的角色信息
  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.swithToHttp().getRequest();
    const user = request.user; // 假使 request 请求对象含有 user 实例及角色信息(实战建议在 request 添加已授权的 user 对象)
    return matchRoles(roles, user.roles);
  }
}

认证(Authentication)

Nest 集成了 Passport 库,内置了 @nestjs/passport 模块。Passport 执行了一下步骤

认证要求

1、首先验证用户的用户名及密码,一旦通过后服务端在响应的头部签发一个 JWT 作为 bearer token 作为后续接口的验证。 2、创建一个受保护的路由仅限被含有效的 JWT 访问。

Passport 提供一个 passport-local 策略,实现基于用户名/密码的认证机制。

Passport 策略

使用原生 Passport, 配置策略时需提供两个信息:

  1. 指定一些配置项信息(如在 JWT 策略中,应提供 secret 对 token 签名)
  2. 验证回调(verify callback)。在验证成功时返回完整的用户信息,失败时返回 null (如密码不匹配或是用户未找到场景)。

在实际项目中,应该使用像加盐单向的哈希算法的 bcrypt 之类的库。使用这种方法,只储存已散列的密码。通过已储存的密码与输入的哈希密码比对。

实现 Passport local

passport-local 默认 request 对象带有 usernamepassword 属性。传一个配置对象用于指定不同的属性名(像 super({usernameField: 'email' }))。

对于每个策略,Passport 使用适当的特定于策略的一组参数调用 verify 函数(使用 @nestjs/Passport 中的 validate() 方法实现)。对于本地策略,Passport 需要一个具有以下签名的 validate() 方法: validate(username: string, password: string): any

通常,每种策略的 validate() 方法的惟一显著差异是如何确定用户是否存在和是否有效。如果用户存在且凭证有效,返回该 user 对象以便 Passport 能完成自己的任务(例如,在 request 对象上创建 user 属性)。否则异常层处理抛出的异常

// local.strategy.ts
import { Strategy } from 'passport-local';
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }
  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.customValidateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

内置 Passport 守卫

守卫主要功能是决定请求是否应由路由处理器处理,它调用 Passport 策略将省略掉需要手动处理的繁琐步骤,类似于检索凭证、运行 verify 函数、创建 user 属性。

登录路由

路由有两种使用访问场景:

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

// 版本 1
@Controller()
export class AppController {
  @UseGuards(AuthGuard('local')) // passport 自带的策略:基于从 `validate()` 方法返回的值,自动创建一个 `user` 属性赋值给 `req` 请求对象。
  @Post('auth/login')
  async login(@Request() req) {
    return req.user;
  }
}

// 版本 2,推荐
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

JWT 功能

当用户使用用户名/密码验证身份后返回有效的 JWT(bearer token)作为后续其他受访问保护的 API。passport-jwt 实现了 JWT 策略的 Passport 包。

// auth/auth.service.ts
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    // `userId` 赋值给 `sub` 属性是为了保持与 JWT 标准一致。
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload), //生成 token
    }
  }
}

// auth.module.ts
@Module({
  imports: [
    UsersModule, // 自定义
    PassportModule, // @nestjs/passport
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' }
    }),
  ],
  providers: [AuthService, LocalStrategy], // LocalStrategy 为自定义本地策略
  exports: [AuthService],
})

// app.controller.ts
@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard) // 守卫用于验证用户名和密码
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user); // AuthService 的 login 函数执行登录
  }
}

实现 Passport JWT

// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //获取 JWT 从 `Request` 对象
      ignoreExpiration: false, // 将 JWT 是否过期的责任委托给 Passport 模块。若过期则请求被拒绝且返回 `401 Unauthorized`
      secretOrKey: jwtConstants.secret, // 提供对称的秘密来签署令牌
    })
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username }; // 返回值作为构建 req.user
  }
}

// auth.module.ts
@Module({
  imports: [
    UsersModule,
    PassportModule, // @nestjs/passport
    JwtModule.register({ // @nestjs/passport
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
});
export class AuthModule {}

// 最终,使用 JwtAuthGuard 去继承内置的 AuthGuard
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

通过导入 JWT 签名时使用的相同密钥,确保 Passport 执行的验证阶段和 AuthService 执行的签名阶段使用公共密钥。

实现保护的路由和 JWT 策略守卫

结合以上,我们可以实现受保护的路由及相关的守卫

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

继承 Guards

继承 AuthGuard 实现覆写错误处理及认证的逻辑

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

全局开启认证

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

请求范围的策略

// local.strategy.ts
constructor(private moduleRef: ModuleRef) { // @nestjs/core
  super({
    passReqToCallback: true, // 请求对象获取当前上下文对象
  });
}

async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request); // 创建 contextId 基于请求对象
  // "AuthService" is a request-scoped provider
  const authService = await this.moduleRef.resolve(AuthService, contextId); // resolve 异步返回一个 request-scoped 的 AuthService 实例
  ...
}

自定义 Passport

PassportModule.register({ session: true }); // 将选项对象直接传递给Passport实例

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

命名策略

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

权限(Authorization)

权限用于决定一个用户可以做什么。权限和认证相互独立,但权限依赖认证机制。

基本 RBAC 实现

RBAC (role-based access control) 是一个基于角色和权限的访问控制策略。

// role.enum.ts
export enum Role {
  User = 'user',
  Admin = 'admin',
}

// roles.decorator.ts
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

// cats.controller.ts
@Post()
@Roles(Role.Admin)
create(@Body() createCatDto: CreateCatDto) {
  this.catService.create(createCatDto);
}

// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

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

基于权利(Claims)的权限

当一个身份创建后被信任一方指派了一个或多个权利。一个权利是一个键值对用于表示能做什么及不能做什么。

要在 Nest 中实现基于权利的权限,参考 RBAC 部分的步骤,仅仅有一个显著区别:比较许可(permissions)而不是角色。

// cats.controller.ts
@Post()
@RequirePermissions(Permission.CREATE_CAT) // Permission 枚举对象包含所有的许可(类似 role)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

集成 CASL

CASL 是一个权限库用于限制用户可以访问哪些资源。它被设计为可渐进式增长的,从基础权利权限到完整的基于主题和属性的权限都可以实现。

例子的需求如下:

  1. 管理员可以管理所有实体(CRUD 所有操作)
  2. 用户对所有内容有阅读权限
  3. 用户可以更新自己的文章(article.authorId === userId
  4. 已发布的不能被删除(article.isPublished === true
export enum Action {
  Manage = 'manage', // `manage` 在 CASL 是一个特殊的关键字表示任何操作
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete'
}

// User and Article entity
class User {
  id: number;
  isAdmin: boolean;
}

class Article {
  id: number;
  isPublished: boolean;
  authorId: number;
}


// all 在 CASL 中是一个特殊的关键字表示任何对象。
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = Ability<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, cannot, build } = new AbilityBuilder<Ability<[Action, Subjects]>>(Ability as AbilityClass<AppAbility>);
    if (user.isAdmin) {
      can(Action.Manage, 'all'); // read-write access to everything
    } else {
      can(Action.Read, 'all'); // read-only access to everything
    }
    can(Action.Update, Article, { authorId: user.id });
    cannot(Action.Delete, Article, { isPublished: true });
    return build({
      // detectSubjectType 选项用于告知 CASL 如何从对象中获取键名。
      detectSubjectType: item => item.constructor as ExtractSubjectType<Subjects>
    })
  }
}

@Module({
  providers: [CaslAbilityFactory],
  exports: [CaslAbilityFactory],
});

// 在 constructor 注入 CaslAbilityFactory 对象
const user = new User();
user.isAdmin = false;

const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Read, Article); // true
ability.can(Action.Delete, Article); // false
ability.can(Action.Create, Article); // false

执行时机: (middleware -> guard -> interceptor (before) -> pipe -> controller -> service -> controller -> interceptor (after) -> filter (if applicable) -> client)