iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Modern Web

NestJS 帶你飛!系列 第 23

[NestJS 帶你飛!] DAY23 - Authentication (上)

相信各位在使用各大網站提供的功能時,都會需要註冊帳號來獲得更多的使用體驗,比如:google、facebook 等,這種帳戶機制可以說是非常重要的一環,在現今的應用上已經可以視為標配。

而一個應用程式可能會有非常多種的註冊方式,比如:本地帳號註冊方式、使用 facebook 註冊、使用 google 註冊等,每一種帳號註冊方式都有一套自己的 策略(Strategy),那要怎麼管理各種 帳戶驗證(Authentication) 的策略也是非常重要的,我們會希望各種策略都能採用同一套標準來進行開發,這時候就可以透過一些工具來輔助我們處理這件事,在 node.js 圈子中,最熱門的帳戶驗證管理工具即 Passport.js (簡稱:passport),而 Nest 也有將其包裝成模組,讓開發人員輕鬆在 Nest 中使用 passport,模組名稱為 PassportModule

passport 介紹

passport 採用了 策略模式 來管理各種驗證方式,它主要由兩個部分構成整個帳戶驗證程序,分別為:passportpassport strategypassport 本身是用來處理 驗證流程 的,而 passport strategy 則是 驗證機制,兩者缺一不可,整個 passport 生態系有上百種的驗證機制讓開發人員使用,如:facebook 驗證策略、google 驗證策略、本地驗證策略等,完美解決各種驗證機制的處理。
https://ithelp.ithome.com.tw/upload/images/20210701/20119338qiADpV5Im1.png

在 Nest 中,passport strategy 會與 Guard 進行搭配,透過 AuthGuardstrategy 包裝起來,就可以透過 Nest 的 Guard 機制來與 passport 做完美的搭配!
https://ithelp.ithome.com.tw/upload/images/20210706/20119338WcjWCkYcvr.png

安裝 passport

透過 npm 來安裝 passport,需要安裝 Nest 包裝的模組以及 passport 本身:

$ npm install @nestjs/passport passport

注意:目前僅安裝了 passport,前面有提到還需要 passport strategy 來滿足完整的驗證程序,這部分後面會再額外進行安裝。

實作帳戶註冊

在開始實作帳戶驗證之前,需要先設計一個帳戶註冊的 API,好讓使用者可以順利註冊成為會員,這裡我們以 MongoDB 作為資料庫,並使用上一篇的技巧來完成資料庫的操作。

注意:這裡會略過 MongooseModuleAppModule 註冊的部分,詳情可以參考上一篇的「連線 MongoDB」。

定義 schema

既然是帳戶註冊,那就跟「使用者」的資料息息相關,故我們要建立一個名為 Userschema 來定義使用者的資料結構。我們在 src/common/models 下新增一個名為 user.model.ts 的檔案,並將使用者的 schemaDocumentschema 實體 與 ModelDefinition 在這裡做定義:

import { ModelDefinition, Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop({
    required: true,
    minlength: 6,
    maxlength: 16
  })
  username: string;
  
  @Prop({
    required: true
  })
  email: string;

  @Prop({
    type: raw({
      hash: String,
      salt: String
    }),
    required: true
  })
  password: Record<string, any>;
}

export const UserSchema = SchemaFactory.createForClass(User);

export const USER_MODEL_TOKEN = User.name;

export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};

可以看到共設計了三個欄位,分別為:usernameemailpassword,其中,password 為巢狀結構,原因是我們不希望密碼直接儲存在資料庫裡面,而是透過密碼學中的加鹽來替密碼進行加密。

鹽加密

https://ithelp.ithome.com.tw/upload/images/20210701/20119338HLYbGc9lX2.png

鹽加密經常用在密碼管理,它的概念很簡單,就是將 輸入值(input)某個特定的值(salt) 進行加密,最後會得出一個 結果(hash),只要將 salthash 存入資料庫就可以避免把原始密碼直接儲存的問題,不過為什麼是儲存這兩個值呢?這就要解釋一下解密的原理了,使用者在登入的時候,會提供我們 usernamepassword 這兩個值,這時候我們就要用使用者提供的 username 去找出對應的使用者資料,如果有找到的話就要來驗證 password 的值是否正確,我們只要用 passwordsalt 再進行一次加密,並用計算出來的值跟 hash 做比對,如果完全相同就表示這個使用者提供的密碼與當初在註冊時提供的密碼是相同的。

我們來實作一個共用的方法來處理鹽加密,在 src/core/utils 下新增一個 common.utility.ts 檔案,並設計一個靜態方法 encryptBySalt,它有兩個參數:inputsalt,其中,salt 的預設值為 randomBytes 計算出來的值,而 inputsalt 透過 pbkdf2Sync 進行 SHA-256 加密並迭代 1000 次,最終返回 hashsalt

import { randomBytes, pbkdf2Sync } from 'crypto';

export class CommonUtility {

  public static encryptBySalt(
    input: string,
    salt = randomBytes(16).toString('hex'),
  ) {
    const hash = pbkdf2Sync(input, salt, 1000, 64, 'sha256').toString('hex');
    return { hash, salt };
  }

}

模組設計

在完成 schema 與加密演算之後,就可以來設計註冊的 API 了,我們會需要建立兩個模組:UserModuleAuthModuleUserModule 是用來處理與使用者相關的操作,而 AuthModule 則是處理與身分驗證有關的操作,基本上 AuthModule 必定與 UserModule 產生依賴,因為要有使用者才有辦法做身分驗證!

使用者模組

透過 CLI 在 src/features 下產生 UserModuleUserService

$ nest generate module features/user
$ nest generate service features/user

UserModule 因為要對使用者資料進行操作,需要使用 MongooseModule 來建立 model,又因為 AuthModule 會依賴於 UserModule 去操作使用者資料,故我們要將 UserService 匯出讓 AuthModule 可以透過 UserService 去操作使用者資料:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { UserDefinition } from '../../common/models/user.model';
import { UserService } from './user.service';

@Module({
  imports: [MongooseModule.forFeature([UserDefinition])],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

根據我們設計的使用者資料結構,我們可以設計一個 DTO 來給定參數型別與進行簡單的資料驗證,在 src/features/user/dto 下新增 create-user.dto.ts

import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @MinLength(6)
  @MaxLength(16)
  public readonly username: string;

  @MinLength(8)
  @MaxLength(20)
  public readonly password: string;

  @IsNotEmpty()
  public readonly email: string;
}

我們直接在 AppModule 透過依賴注入的方式來啟用 ValidationPipe

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';

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

最後,我們要在 UserService 注入 model 並設計 createUser(user: CreateUserDto) 方法來建立使用者,其中,password 需要透過鹽加密來處理:

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

import { Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }
}

如果覺得 import 時的路徑太繁瑣,可以做一個 index.ts 來將對外的部分做統一的匯出:

export { UserModule } from './user.module';
export { UserService } from './user.service';
export { CreateUserDto } from './dto/create-user.dto';

驗證模組

透過 CLI 在 src/features 下產生 AuthModuleAuthController

$ nest generate module features/auth
$ nest generate controller features/auth

AuthModule 中匯入 UserModule

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

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

接著,在 AuthController 設計一個 [POST] /auth/signup 的 API,並調用 UserServicecreateUser(user: CreateUserDto) 方法來建立使用者:

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto, UserService } from '../user';

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

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

透過 Postman 進行測試:
https://ithelp.ithome.com.tw/upload/images/20210705/20119338Ca3i3PBpO3.png

實作本地帳戶登入

帳戶驗證與登入息息相關,在登入的過程中,會進行一些帳號密碼的檢測,檢測通過之後便完成登入程序。本地帳戶登入可以使用 passport-local 這個 strategypassport 進行搭配,透過 npm 進行安裝即可:

$ npm install passport-local
$ npm install @types/passport-local -D

實作策略

首先,我們需要先在 UserService 添加一個 findUser 方法來取得使用者資料,用途是讓使用者輸入 usernamepassword 後,可以去資料庫中尋找對應的使用者:

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

import { FilterQuery, Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }

  findUser(filter: FilterQuery<UserDocument>) {
    return this.userModel.findOne(filter).exec();
  }
}

透過 CLI 產生 AuthService 來處理檢測帳戶的工作:

$ nest generate service features/auth

AuthService 設計一個 validateUser(username: string, password: string) 的方法,先透過 username 尋找對應的使用者資料,再針對使用者輸入的密碼與 salt 進行鹽加密,如果結果與資料庫中的 hash 相同,就回傳使用者資料,否則回傳 null

import { Injectable } from '@nestjs/common';
import { CommonUtility } from '../../core/utils/common.utility';
import { UserService } from '../user';

@Injectable()
export class AuthService {

  constructor(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;
  }

}

完成了使用者驗證的方法後,就要來將它與 passport 的機制接上,我們需要建立一個 Provider 來作為 strategy,透過該 strategy 即可與 passport 進行介接。

src/features/auth 底下建立一個 stratgies 資料夾並建立 local.strategy.ts,在這個檔案中實作一個 LocalStrategyclass,需特別注意的是該 class 要繼承 passport-localstrategy,但需要透過 Nest 製作的 function 來與它做串接,並實作 validate(username: string, password: string) 方法,該方法即為 passport 流程的 進入點,在這裡我們就用呼叫剛剛在 AuthService 實作的方法來進行帳號驗證:

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 { username: user.username, email: user.email };
  }

}

別忘了在 AuthModule 匯入 PassportModule 與在 providers 裡面添加 LocalStrategy

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

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

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

使用 AuthGuard

實作完 stragegy 以後,就要實作一個 API 來處理登入驗證,我們在 AuthController 添加一個 signin 方法並套用 AuthGuard,因為我們是使用 passport-local 這個 strategy,所以要在 AuthGuard 帶入 local 這個字串,passport 會自動與 LocalStrategy 進行串接,然後 passport 會將 LocalStrategyvalidate 方法回傳的值寫入 請求物件user 屬性中,這樣就可以在 Controller 中使用該使用者的資訊:

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

import { Request } from 'express';

import { CreateUserDto, UserService } from '../user';

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

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

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

透過 Postman 進行測試,會順利得到對應的使用者資料:
https://ithelp.ithome.com.tw/upload/images/20210706/20119338NDRSU2EStw.png

小結

今天我們實作了註冊與登入的功能並且初步了解到 passport 在 Nest 中如何使用,不過這些都只算是 登入前 的處理,還有 登入後 要怎麼保持授權狀態等步驟要處理,這部分就留到下篇跟各位詳細說明吧!這裡附上今天的懶人包:

  1. passport 採用策略模式來管理各種帳戶驗證的方式。
  2. passport 需要搭配 strategy 來完成完整的帳戶驗證程序。
  3. AuthGuard 作為 Guard 並指定要使用哪個 strategy 進行驗證。
  4. 儲存密碼不可以明碼儲存,建議使用鹽加密處理。
  5. 運用 passport-local 來處理本地帳戶登入的驗證。

上一篇
[NestJS 帶你飛!] DAY22 - MongoDB
下一篇
[NestJS 帶你飛!] DAY24 - Authentication (下)
系列文
NestJS 帶你飛!32

尚未有邦友留言

立即登入留言