跟随官网学nestjs之守卫

上篇讲到 Nest 的入门,这篇讲下在 Nest 中守卫,也叫授权。
我们现在就以常见的身份认证作为示例,通过登录获取token,在特定的请求头需要带上token,并校验token是否过期

身份认证

Passport 是最流行的 node.js 身份验证库。将这个库与使用 @nestjs/passport 模块的 Nest 应用程序集成起来非常简单。这是官方推荐的一个库,接下来我们就使用该库来实现认证

安装

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

Passport 提供了一种名为 Passport-local 的策略,它实现了一种用户名/密码身份验证机制。关于其策略,官网都有详细的说明,我们接下来就看具体实现。

实现

我们新增一个 auth 模块,用于权限验证;在实现这块功能时,你就会发现我们之前写的 user 模块是很有用的。

nest g controller auth
nest g service auth
nest g module auth

生成 auth.controller.tsauth.service.tsauth.module.ts 文件,现在我们来完善它们

  • 新增登录接口

auth.controller.ts

import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiForbiddenResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto';

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

  @ApiOperation({ summary: 'user login' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @Post('/login')
  async login(@Body() _: LoginDto) {
    return this.authService.login(_);
  }
}

auth.service.ts 提供登录服务,并返回 token

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

@Injectable()
export class AuthService {
  async login(user) {
    return {
      token: `Bearer token`,
    };
  }
}

auth.module.ts 提供 auth.service

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

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

更新 app.module.ts

import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { UserModule } from './user/user.module';
import { User } from './user/user.model';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    UserModule,
    AuthModule,
    SequelizeModule.forRoot({
      dialect: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'mysql_demo',
      models: [User],
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

这时刷新浏览器,就能看见新增了一个接口,我们执行一下,就能看见接口返回的模拟 token

img

那么接下来我们就是需要完善登录这个服务了,我们的需求是用户输入账号密码,服务端去数据库查找,验证是否能够匹配的上,所以 service 的任务是检索用户并验证密码。我们提供一个 validateUser 方法,调用 user 模块的服务查找该用户的账号,并对比输入的密码与返回的密码是否一致

auth.service.ts

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

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

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

  async login(user) {
    return {
      token: `Bearer token`,
    };
  }
}

user.service.ts 需要新增一个通过用户名查询用户的服务,并返回用户的密码

  async findOneByName(username: string): Promise<User> {
    return await this.userModel.findOne<User>({
      where: { username },
      attributes: ['id', 'username', 'password'],
    });
  }

好了,现在查询用户的服务有了,校验用户身份的方法也有了,那么我们就可以实现 Passport 本地身份验证策略了。

  • Passport 本地策略

auth 文件下新建 local.strategy.ts

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

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

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

passport-local 用例中,没有配置选项,因此我们的构造函数只是调用 super() ,没有 options 对象。

对于每个策略,Passport 将使用适当的特定于策略的一组参数调用 verify 函数(使用 @nestjs/Passport 中的 validate() 方法实现)。对于本地策略,Passport 需要一个具有以下签名的 validate() 方法: validate(username: string, password: string): any。任何 Passport 策略的 validate() 方法都将遵循类似的模式,只是表示凭证的细节方面有所不同。如果找到了用户并且凭据有效,则返回该用户,以便 Passport 能够完成其任务,并且请求处理管道可以继续。如果没有找到,抛出一个异常,让异常层处理它。

更新一下 auth.module

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

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

内置守卫

守卫的主要功能:确定请求是否由路由处理程序。简单来说,我们针对不同的路由可能会存在不同的权限管控,如:auth/login 我们就需要用户名/密码凭证来启动身份验证;而其他接口(获取用户信息)我们就需要启用令牌 token(也就是 JWT 机制)来检验。

当然 @nestjs/passport 模块为我们提供了一个内置的守卫,我们可以应用内置的守卫来启动护照本地流。

auth.controller.ts 为其添加本地守卫

import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiForbiddenResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto';
import { LocalAuthGuard } from './local-auth.guard';

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

  @ApiOperation({ summary: 'user login' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Body() _: LoginDto) {
    return this.authService.login(_);
  }
}

这里 @UseGuards(LocalAuthGuard) 就是一个守卫,也有这样写的 @UseGuards(AuthGuard('local') ;对于前者,我们需新建一个 guard 文件来负责,其实现很简单

local-auth.guard.ts

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

这样的好处是,我们能清楚的知道每个守卫自己的职责,随着业务的扩展,守卫也会随之增加,也方便对其守卫进行一些扩展。

Passport 会根据从 validate 方法返回的值自动创建一个 user 对象,并将其作为 req.user 分配给请求对象。因此我们需要修改 auth.controller.ts 来接受这个返回值

import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiForbiddenResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto';
import { LocalAuthGuard } from './local-auth.guard';

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

  @ApiOperation({ summary: 'user login' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Body() _: LoginDto, @Request() req) {
    return req.user.dataValues;
  }
}

此时,我们调用登录接口,当我们输入错误的账号密码时,就会发现接口返回401的状态码,告诉我们无权限,当我们输入正取的账号密码时,就能看见返回该用户

img

说明我们的校验生效了,回到最开始,我们是希望登录返回一个 tokenJWT),而 token 就是包含了用户信息,过期时间等;接下来我们就实现 token

JWT 功能

JWT 全称是 JSON Web Token,是目前最流行的跨域认证解决方案;其原理就是服务器认证以后,生成一个 JSON 对象,包含了用户基本信息以及过期时间,然后返回给调用者,而后有权限的 API 都需要带上该信息

安装

$ npm install @nestjs/jwt passport-jwt
$ npm install @types/passport-jwt --save-dev

实现

之前我们说到 LocalAuthGuard 守卫会返回 user 并以 req.user 给到请求者,现在我们就来完善生成 token

auth.service.ts

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

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

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

  async login(user: {username: string, id: string}) {
    const payload = { username: user.username, sub: user.id };
    return {
      access_token: `Bearer ${this.jwtService.sign(payload)}`,
    };
  }
}

@nestjs/jwt 库提供了一个 sign 函数,用于从用户对象属性的子集生成 jwt,然后返回 access_token

接下来需要更新 auth.module.ts 来导入新的依赖项并配置 JwtModule。需要创建一个共享密钥用在 JWT 签名和验证步骤之间,为了方便,我们新建 constants.ts 来存放该密钥,但在正式的生产环境中不推荐这样做,因为密钥需要一定的保护措施,你可以放在环境变量中或者其他的方式也可以,总之要有一定的安全保护

constants.ts

export const jwtConstants = {
  secret: 'secretKey',
};

auth.module.ts

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

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

这里我们使用 register 配置 JwtModule ,并传入一个配置对象,更多的配置项可以查看官网

接下来修改 auth.controller.tslogin 接口,使其调用 servicelogin 方法

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

  @ApiOperation({ summary: 'user login' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Body() _: LoginDto, @Request() req) {
    return this.authService.login(req.user.dataValues);
  }
}

我们重新请求下登录接口,就能看到返回的数据就是 access_token

img

拦截

接下来我们就可以使用 token 来对 API 路由进行校验;passport-jwt 策略可以用于用 JSON Web 标记保护 RESTful 端点。
新建 jwt.strategy.ts

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

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

  async validate(payload: any) {
    return { id: payload.sub, username: payload.username };
  }
}
  • jwtFromRequest:提供从请求中提取 JWT 的方法。我们将使用在 API 请求的授权头中提供 token 的标准方法
  • ignoreExpiration:选择默认设置 false ,它将确保 JWT 没有过期的责任委托给 Passport 模块。这意味着,如果我们的路由提供了一个过期的 JWT ,请求将被拒绝,并发送 401 未经授权的响应
  • secretOrkey:使用权宜的选项来提供对称的密钥来签署令牌
  • validate:对于 JWT 策略,Passport 首先验证 JWT 的签名并解码 JSON 。然后调用 validate 方法,该方法将解码后的 JSON 作为其单个参数传递

auth.module.ts 中添加新的 JwtStrategy 作为提供者

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

新建 jwt-auth.guard.ts

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }

  handleRequest(err, user) {
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

上面我们看到 jwt.strategy.tsvalidate 方法会返回 user;在 handleRequest 我们可以看到如果找不到 user 就会抛出没有权限的错,我们在获取用户的接口加上该拦截

user.controller.ts

import { Controller, Get, Param, UseGuards, Post, Body } from '@nestjs/common';
import {
  ApiTags,
  ApiOperation,
  ApiUnauthorizedResponse,
  ApiForbiddenResponse,
  ApiNotFoundResponse,
} from '@nestjs/swagger';
import { CreateUserDto } from './dto';
import { UserService } from './user.service';
import { User } from './user.model';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@ApiTags('user')
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @ApiOperation({
    summary: 'find a user by id',
  })
  @ApiUnauthorizedResponse({ description: 'Unauthorized' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(JwtAuthGuard)
  @Get(':id')
  findOne(@Param('id') id: string): Promise<User> {
    return this.userService.findOneById(id);
  }

  @ApiOperation({
    summary: 'find user list',
  })
  @ApiUnauthorizedResponse({ description: 'Unauthorized' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(JwtAuthGuard)
  @Get()
  findAll(): Promise<User[]> {
    return this.userService.findAll();
  }

  @ApiOperation({
    summary: 'create a user',
  })
  @ApiUnauthorizedResponse({ description: 'Unauthorized' })
  @ApiForbiddenResponse({ description: 'Forbidden' })
  @ApiNotFoundResponse({ description: 'Not Found' })
  @UseGuards(JwtAuthGuard)
  @Post()
  create(
    @Body() createUserDto: CreateUserDto,
  ): Promise<User> {
    return this.userService.create(createUserDto);
  }
}

这时我们之前去访问接口,就能看见接口报 401

img

给文档加权限

swagger 提供了装饰器,可以在请求头带上 token
修改 main.ts

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const options = new DocumentBuilder()
    .setTitle('nest demo example')
    .setDescription('The nest demo API description')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('api-docs', app, document);

  await app.listen(3000);
}
bootstrap();

然后在 user.controller.ts 文件里的接口加上 @ApiBearerAuth() 装饰器,这时我们看见接口问题出现了 Authorizate 图标

img

我们点击那个锁,就会弹窗一个弹窗让我们填写 token,我们调用登录接口,把返回回来的 access_token 值粘贴上,就能正常访问需要权限的接口了,注意由于之前返回带上了 Bearer 前缀,所以在粘贴的时候需要去掉

img

到此守卫就入门了,接下来期待自己深入的研究

代码传送门:nest-demo

参考资料:Nest 文档

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容