[NestJS 带你飞!] DAY24 - Authentication (下)

上一篇已经处理好注册与登入的部分,但一个完整的帐户机制还需要包含 登入後 的身份识别,为什麽登入後还要做身份识别呢?试想今天如果只有注册与登入功能的话,当使用者登入後要在系统上使用某个会员功能时,该如何辨识的这个使用者是谁呢?要实作这样的识别功能有很多种做法,Token 正是其中一个被广泛运用的方案。

Token 的概念

Token 就是一个用来表示身份的媒介,当使用者成功登入时,系统会产生出一个独一无二的 Token,并将该 Token 返回给使用者,只要在 Token 有效的期间内,该使用者在请求中带上该 Token,系统便会识别出此操作的使用者是谁。
https://ithelp.ithome.com.tw/upload/images/20210708/201193387ppuU9jsMM.png

在近几年有一项 Token 技术非常热门,其名为 Json Web Token (简称:JWT),本篇的身份识别就会用 JWT 来实作!

什麽是 JWT?

JWT 是一种较新的 Token 设计方法,它最大的特点是可以在 Token 中含有使用者资讯,不过仅限於较不敏感的内容,比如:使用者名称、性别等,原因是 JWT 是用 Base64 进行编码,使用者资讯可以透过 Base64 进行 还原,使用上需要特别留意!

一个 JWT 的格式如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhBTyIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9.d704zBOIq6KNcexbkfBTS5snNa9tXz-RXo7Wi4Xf6RA

会发现整个字串被两个「.」切割成三段,这三段可以透过 Base64 进行解码,它们各自有不同的内容:

标头

标头为 JWT 第一段的部分,其内容包含「加密演算法」与「Token 类型」。上方 JWT 的标头进行解码可以得出下方资讯:

{
  "alg": "HS256",
  "typ": "JWT"
}

内容

内容为 JWT 第二段的部分,这里通常会放一些简单的使用者资讯。上方 JWT 的内容进行解码可以得出下方资讯:

{
  "sub": "1234567890",
  "name": "HAO",
  "admin": true,
  "iat": 1516239022
}

签章

签章为 JWT 第三段的部分,用来防止被窜改,在後端需要维护一组密钥来替 JWT 进行签章,密钥需要妥善保存避免被有心人士获取!

安装 JWT

在开始实作之前,先透过 npm 安装 JWT 所需的套件,主要有 Nest 包装的模组、passport-jwt 以及其型别定义档:

$ npm install @nestjs/jwt passport-jwt
$ npm install @types/passport-jwt -D

实作 JWT 验证

首先,我们要先定义一组密钥来进行 JWT 的签章,并将该密钥放至 .env 中:

JWT_SECRET=YOUR_SECRET

接着,在 src/config 资料夹下新增 secret.config.ts,将密钥类型的环境变数整合至 secrets 底下:

import { registerAs } from '@nestjs/config';

export default registerAs('secrets', () => {
  const jwt = process.env.JWT_SECRET;
  return { jwt };
});

app.module.ts 中进行套用:

import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './features/user/user.module';
import { AuthModule } from './features/auth/auth.module';

import MongoConfigFactory from './config/mongo.config';
import SecretConfigFactory from './config/secret.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [MongoConfigFactory, SecretConfigFactory], // 套用至 ConfigModule
      isGlobal: true
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
      }),
    }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

产生 JWT

完成密钥的配置後,就来配置 JWT 吧!我们在处理验证的 AuthModule 中汇入 JwtModule,并使用 registerAsync 方法来配置 JWT 的设定,最重要的就是将密钥带入:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';

import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';

@Module({
  imports: [
    PassportModule,
    UserModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        const secret = config.get('secrets.jwt');
        return {
          secret,
          signOptions: {
            expiresIn: '60s'
          }
        };
      },
    })
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

注意:本篇主要是实作一个简单的身分识别功能,所以详细的 JwtModule 配置项请参考 官方文件 以及 node-jsonwebtoken

上一篇我们是让使用者登入後获得使用者资料,这篇我们将会把这个机制更换成回传 JWT,让使用者可以顺利拿到它来使用会员功能,所以我们要在 AuthService 设计一个 generateJwt 方法来调用 JwtServicesign 方法产生 JWT,该方法需要带入要放在「内容」区块的资料,这里我们就放入使用者的 idusername

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

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument } from '../../common/models/user.model';
import { UserService } from '../user';

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

  async validateUser(username: string, password: string) {
    const user = await this.userService.findUser({ username });
    const { hash } = CommonUtility.encryptBySalt(
      password,
      user?.password?.salt,
    );
    if (!user || hash !== user?.password?.hash) {
      return null;
    }
    return user;
  }

  generateJwt(user: UserDocument) {
    const { _id: id, username } = user;
    const payload = { id, username };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

我们上一篇在 LocalStrategy 设定回传值只有 usernameemail,这不符合我们产生 JWT 所需的资料,所以改成直接回传整个使用者资料:

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

import { AuthService } from '../auth.service';

@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;
  }
}

注意validate 最好是只回传重点资料。

最後就是在 AuthControllersignin 方法回传 generateJwt 的结果:

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

import { Request } from 'express';

import { UserDocument } from '../../common/models/user.model';
import { CreateUserDto, UserService } from '../user';
import { AuthService } from './auth.service';

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

  @Post('/signup')
  signup(@Body() user: CreateUserDto) {
    return this.userService.createUser(user);
  }

  @UseGuards(AuthGuard('local'))
  @Post('/signin')
  signin(@Req() request: Request) {
    return this.authService.generateJwt(request.user as UserDocument);
  }
}

透过 Postman 进行登入测试,成功的话会获得 access_token
https://ithelp.ithome.com.tw/upload/images/20210712/20119338rnh6D3nL7J.png

验证 JWT

接下来我们要制作 JwtStrategypassport 进行串接,跟 LocalStrategy 的实作方式大同小异,必须继承 passport-jwtstrategy,比较不同的地方在於 super 带入的参数。我们先在 src/features/auth/strategies 资料夹下新增 jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {

  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('secrets.jwt'),
    });
  }

  validate(payload: any) {
    const { id, username } = payload;
    return { id, username };
  }
}

可以看到 super 带入了三个参数:

  1. jwtFromRequest:指定从请求中的哪里提取 JWT,这里可以使用 ExtractJwt 来辅助配置。
  2. ignoreExpiration:是否忽略过期的 JWT,预设是 false
  3. secretOrKey:放入 JWT 签章用的密钥。

注意:更多的参数内容请参考 官方文件

可以注意一下 validate 这个方法,基本上 JWT 在流程上就已经验证了其合法性与是否过期,故这里 可以不用 进行额外的检查,但如果要在这里向资料库提取更多的使用者资讯也是可以的。

完成 JwtStrategy 後记得要在 AuthModuleproviders 里面添加它:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';

import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    PassportModule,
    UserModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        const secret = config.get('secrets.jwt');
        return {
          secret,
          signOptions: {
            expiresIn: '60s'
          }
        };
      },
    })
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

最後,我们设计一个取得使用者资料的 API 来套用 JWT 验证,透过 CLI 产生 UserController

$ nest generate controller features/user

然後修改一下 user.controller.ts 的内容:

import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UserService } from './user.service';

@Controller('users')
export class UserController {

  constructor(private readonly userService: UserService) {}

  @UseGuards(AuthGuard('jwt'))
  @Get(':id')
  async getUser(@Param('id') id: string) {
    const user = await this.userService.findUser({ _id: id });
    const { password, ...others } = user.toJSON();
    return others;
  }

}

getUser 方法套用 AuthGuard 并指定使用 jwt 策略,将传入的 id 向资料库进行查询,取得 UserDocument 後,先把它转换成 JSON 格式,再透过解构的方式将 password 以外的属性回传到客户端。

先透过 Postman 进行登入取得 access_token,并将其带入 Bearer token 中来测试取得使用者资料的 API:
https://ithelp.ithome.com.tw/upload/images/20210712/20119338ixXxdc7GYd.png

如果带入过期或是错误的 JWT 则会收到下方错误讯息:
https://ithelp.ithome.com.tw/upload/images/20210712/20119338RGzkqltHUB.png

小结

这两天的内容实现了一套简单的本地身份验证机制,相信大家已经了解 passport 的概念与使用方式了,有兴趣的读者可以尝试串接 FacebookGoogle 的验证机制。这里附上今天的懒人包:

  1. JWT 是一种较新的 Token 设计方法,它最大的特点是可以在 Token 中含有使用者资讯。
  2. JWT 透过 Base64 进行编码。
  3. JWT 被「.」分成了三段,分别是:标头、内容、签章。
  4. JWT 需要使用一组密钥进行签章,该密钥必须妥善保管。
  5. JwtModule 配置密钥、期限等参数,主要是用来建立 JWT。
  6. JwtStrategysuper 需要指定一些参数,主要是用来验证 JWT 的。

<<:  敏捷开发 组别

>>:  【Day24】Git 版本控制 - 修改 commit 纪录:amend

[神经机器翻译理论与实作] 你只需要专注力(I): Attention Mechanism

前言 Google 翻译团队在2016年发表了重要文章《Google’s Neural Machin...

[Day29] 第二十九课 Azure灾害复原(DRaaS)-2[进阶]

我们来接续昨日Azure Site Recovery(ASR)的进度之前, 我想补充一下地端及云端容...

Javascript 传值传址&深浅拷贝

前言 因为公司前端资料已经处理成单层结构,所以都没注意到浅拷贝、深拷贝的实际差别。 在读完高手文章後...

#20 Telegram Bot Webhook 讯息收发

今天来尝试部署 Cloudflare Workers 并写写看简单的信息收发。 response 如...

鬼故事 - 糟了,是世界奇观

鬼故事 - 糟了,是世界奇观 Credit: Unkonwn (Skritch, Skritch) ...