目标
我们要实现的目标是:首先客户端会使用用户名和密码进行身份验证,身份验证通过以后,服务端将会下发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认证,并实现了可选式路由访问保护。