NestJS(三):守卫与身份验证

1,451 阅读5分钟

目标

我们要实现的目标是:首先客户端会使用用户名和密码进行身份验证,身份验证通过以后,服务端将会下发JWT,随后该JWT会在客户端的后续请求的Authorization请求头中作为token发送给服务端以验证身份,最后利用守卫创建受保护的路由,即只有携带有效token的请求才能访问的路由。代码github地址:github.com/urarav/admi…

登录

首先在UserService中创建查询单个用户服务:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { FindOptionsWhere, Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
  ) {}

  async findOne(opt: FindOptionsWhere<User>) {
    return await this.userRepository.findOne({
      where: opt,
    });
  }
}

UserService添加到 @Module 装饰器的 exports 数组中,以便提供给其他模块使用:

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

接下来就可以在Auth模块中导入UserService并实现登录接口,新建Auth模块:

$ nest g res auth --no-spec

AuthService中创建校验用户服务:

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
  ) {}

  async validateUser(username: string, password: string) {
    const targetUser = await this.userService.findOne({ username });
    if (!targetUser) return null;
    const { username: name, id, password: hash } = targetUser;
    const isMatch = await bcrypt.compare(password, hash);
    return isMatch ? { username: name, id } : null;
  }
}

当然别忘了在AuthModule中导入刚才暴露查找用户服务的UserModule

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';

@Module({
  imports: [UserModule],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

注意,这里我们使用Passport库来完成身份验证,Passport是最流行的 NodeJS 身份验证库,具有丰富的策略生态系统,可提供各种身份验证机制。一般来说我们需要提供以下两项完成策略配置:

  • 特定于该策略的选项
  • 验证回调:在这里验证用户是否存在(或创建一个新用户),以及他们的凭据是否有效。Passport 库期望这个回调在验证成功时返回完整的用户消息,在验证失败(用户不存在,或者在使用 Passport-local 的情况下密码不匹配)时返回 null(即上面AuthService代码中的validateUser服务)。

通过使用 @nestjs/passport ,可以通过扩展 PassportStrategy 类来配置 passport 策略。通过调用子类中的 super() 方法传递策略选项(上面第 1 项);通过在子类中实现 validate() 方法,可以提供verify 回调(上面第 2 项)。

Passport 提供了一种名为 Passport-local 的策略,它实现了一种用户名/密码身份验证机制,在这里被用来验证登录用户身份,首先安装所需的依赖包:

$ yarn add --save @nestjs/passport passport passport-local
$ yarn add --save-dev @types/passport-local

现在我们就可以实现 Passport-local 策略了!在auth文件夹中创建strategy/local.strategy.ts 文件,并添加以下代码:

import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) throw new UnauthorizedException();
    return user;
  }
}

正如上述代码所示,对于每个策略,Passport 将使用适用于特定策略的一组参数去调用 verify 函数(使用 @nestjs/Passport 中的 validate() 方法实现)。对于Passport-local策略,Passport 需要一个具有以下签名的 validate() 方法: validate(username: string, password: string): any。另外,更新AuthModule以应用定义的Passport特性:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategy/local.strategy';

@Module({
  imports: [
    UserModule,
    PassportModule,
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
  ],
})
export class AuthModule {}

至此定义好了Passport-local策略,现在要做的就是在/auth/login路由上实施定义好的策略,那么究竟该如何实施Passport-local策略呢?@nestjs/passport 模块为我们提供了一个内置的守卫,可以完成这一任务。这个守卫调用 Passport-local 策略并启动上面描述的步骤(检索凭据、运行verify 函数、创建user属性等)。在AuthController中实现/auth/login路由:

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

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return req.user;
  }
}

为了测试这里将 /auth/login 路由直接返回user对象。

// request
{
    "username": "admin",
    "password": "123456"
}
// response
{
	"statusCode": 200,
	"data": {
		"username": "admin",
		"id": "157434b8-d7c5-4273-88c5-e6170cd3d71a"
	},
	"success": true,
	"message": "success"
}

// request
{

    "username": "admin",
    "password": "xxx"

}
// response
{
	"statusCode": 401,
	"data": null,
	"success": false,
	"message": "Unauthorized",
	"timestamp": "2023-06-01T14:32:08.355Z",
	"path": "/auth/login"
}

这里验证了另一个Passport重要特性:Passport 根据从 validate() 方法返回的值自动创建一个 user 对象,并将其作为 req.user 分配给请求对象。

JWT

为了实现目标,还需要在身份验证通过后返回JWT以便后续客户端请求受保护的API时使用,首先还是安装所需依赖包:

$ yarn add --save @nestjs/jwt passport-jwt
$ yarn add @types/passport-jwt --save-dev

我们在上面通过使用Passport提供的内置AuthGaurd守卫来装饰路由以将Passport-local策略应用在/auth/login路由,并且我们发现:

  • 只有在用户通过身份验证之后,才会调用路由处理程序;
  • req参数将包含一个user属性(由 Passport 填充)。、

同样的,这也适用于Passport-jwt策略。为此我们需要先在AuthService中添加JWT生成服务:

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  async login(user: any) {
    return this.jwtService.sign({
      username: user.username,
      sub: user.id,
    });
  }

  async validateUser(username: string, password: string) {
    const targetUser = await this.userService.findOne({ username });
    if (!targetUser) return null;
    const { username: name, id, password: hash } = targetUser;
    const isMatch = await bcrypt.compare(password, hash);
    return isMatch ? { username: name, id } : null;
  }
}

更新 AuthModule 来导入新的依赖项并配置 JwtModule

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategy/local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants/constants';

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: {
        expiresIn: '1h',
        algorithm: 'HS256',
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
  ],
})
export class AuthModule {}

更新AuthController/auth/login路由以返回JWT

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

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

测试:

//request
{
    "username": "admin",
    "password": "123456"
}

// response
{
	"statusCode": 200,
	"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwic3ViIjoiMTU3NDM0YjgtZDdjNS00MjczLTg4YzUtZTYxNzBjZDNkNzFhIiwiaWF0IjoxNjg1NjMxOTU4LCJleHAiOjE2ODU2MzU1NTh9.IQFmei2S0XnnHVlpZIXX-LrIBkG5FE5bDHYIpWhfeTY",
	"success": true,
	"message": "success"
}

至此已经能够返回JWT,还需要实现路由保护即可完成目标功能,要实现只有携带有效token的请求才能访问的路由,可以使用Passport提供的Passport-jwt策略。同样的,在auth目录下创建strategy/jwt.strategy.ts文件:

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtConstants } from '../constants/constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { id: payload.sub, username: payload.username };
  }
}

这个策略需要一些初始化,这里通过在 super() 调用中传递一个 options 对象实现。

对于 JWT 策略,Passport 首先验证 JWT 的签名并解码 JSON 。然后调用 validate() 方法并将解码后的 JSON作为唯一参数传递。根据 JWT 签名的工作方式,我们可以保证接收到之前已签名并下发给客户端的有效 token 。再次强调,Passport 将基于 validate() 方法的返回值构建一个user 对象,并将其作为属性附加到请求对象上。

另外,别忘记了在 AuthModule中添加新的JwtStrategy作为providers

有了前面的工作,我们现在可以实现受保护的路由及其相关的守卫。在auth目录下创建gaurd/auth.gaurd.ts

import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

AuthModule中更新providers

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategy/local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants/constants';
import { JwtStrategy } from './strategy/jwt.strategy';
import { JwtAuthGuard } from './guard/auth.guard';

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: {
        expiresIn: '1h',
        algorithm: 'HS256',
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    JwtAuthGuard,
  ],
})
export class AuthModule {}

现在,当我们请求 GET /auth/profile 路由时,守卫程序将自动调用我们的 Passport-jwt 自定义逻辑,验证 JWT ,并将user属性分配给请求对象。

import { Request, Controller, Post, UseGuards, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import { JwtAuthGuard } from './guard/auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

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

如果应用程序大多数路由都应该默认受到保护,可以将身份验证守卫注册为全局守卫,而不是在每个控制器上使用 @UseGuards() 装饰器:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategy/local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants/constants';
import { JwtStrategy } from './strategy/jwt.strategy';
import { JwtAuthGuard } from './guard/auth.guard';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: {
        expiresIn: '1h',
        algorithm: 'HS256',
      },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    // JwtAuthGuard,
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AuthModule {}

但是并不是全部路由都需要受到保护(注册、登录等),我们需要一种机制来声明哪些路由是公开的,为此,我们可以使用 SetMetadata 装饰器工厂函数创建一个自定义装饰器。在auth目录下创建decorator/skip-auth.decorator.ts

import { SetMetadata } from '@nestjs/common';  
import { IS_SKIP_AUTH } from '../constants/constants';  
 
// IS_SKIP_AUTH: 'isSkipAuth'
export const SkipAuth = (isSkip = true) => SetMetadata(IS_SKIP_AUTH, isSkip);

现在我们有了一个自定义的 @SkipAuth() 装饰器,我们可以用它来装饰任何方法,如下所示:

import { Request, Controller, Post, UseGuards, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import { SkipAuth } from './decorator/skip-auth.decorator';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @UseGuards(AuthGuard('local'))
  @SkipAuth()
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

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

最后,我们需要在找到 isSkipAuth 元数据时,让 JwtAuthGuard 返回 true。为此,我们将使用 Reflector 类

import { AuthGuard } from '@nestjs/passport';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { IS_SKIP_AUTH } from '../constants/constants';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isSkipAuth = this.reflector.getAllAndOverride<boolean>(IS_SKIP_AUTH, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isSkipAuth) {
      return true;
    }
    return super.canActivate(context);
  }
}

这样,就能够有选择的对路由进行权限控制了。

总结

本文主要记录了如何利用Passport及其策略完成登录身份验证和JWT认证,并实现了可选式路由访问保护。