iT邦幫忙

2021 iThome 鐵人賽

DAY 24
0
Modern Web

NestJS 帶你飛!系列 第 24

[NestJS 帶你飛!] DAY24 - Authentication (下)

本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!

上一篇已經處理好註冊與登入的部分,但一個完整的帳戶機制還需要包含 登入後 的身份識別,為什麼登入後還要做身份識別呢?試想今天如果只有註冊與登入功能的話,當使用者登入後要在系統上使用某個會員功能時,該如何辨識的這個使用者是誰呢?要實作這樣的識別功能有很多種做法,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 的。

上一篇
[NestJS 帶你飛!] DAY23 - Authentication (上)
下一篇
[NestJS 帶你飛!] DAY25 - Authorization & RBAC
系列文
NestJS 帶你飛!32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言