本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
相信各位在使用各大網站提供的功能時,都會需要註冊帳號來獲得更多的使用體驗,比如:google、facebook 等,這種帳戶機制可以說是非常重要的一環,在現今的應用上已經可以視為標配。
而一個應用程式可能會有非常多種的註冊方式,比如:本地帳號註冊方式、使用 facebook 註冊、使用 google 註冊等,每一種帳號註冊方式都有一套自己的 策略(Strategy),那要怎麼管理各種 帳戶驗證(Authentication) 的策略也是非常重要的,我們會希望各種策略都能採用同一套標準來進行開發,這時候就可以透過一些工具來輔助我們處理這件事,在 node.js 圈子中,最熱門的帳戶驗證管理工具即 Passport.js (簡稱:passport
),而 Nest 也有將其包裝成模組,讓開發人員輕鬆在 Nest 中使用 passport
,模組名稱為 PassportModule
。
passport
採用了 策略模式 來管理各種驗證方式,它主要由兩個部分構成整個帳戶驗證程序,分別為:passport
與 passport strategy
。passport
本身是用來處理 驗證流程 的,而 passport strategy
則是 驗證機制,兩者缺一不可,整個 passport
生態系有上百種的驗證機制讓開發人員使用,如:facebook 驗證策略、google 驗證策略、本地驗證策略等,完美解決各種驗證機制的處理。
在 Nest 中,passport strategy
會與 Guard 進行搭配,透過 AuthGuard
將 strategy
包裝起來,就可以透過 Nest 的 Guard 機制來與 passport
做完美的搭配!
透過 npm
來安裝 passport
,需要安裝 Nest 包裝的模組以及 passport
本身:
$ npm install @nestjs/passport passport
注意:目前僅安裝了
passport
,前面有提到還需要passport strategy
來滿足完整的驗證程序,這部分後面會再額外進行安裝。
在開始實作帳戶驗證之前,需要先設計一個帳戶註冊的 API,好讓使用者可以順利註冊成為會員,這裡我們以 MongoDB 作為資料庫,並使用上一篇的技巧來完成資料庫的操作。
注意:這裡會略過
MongooseModule
在AppModule
註冊的部分,詳情可以參考上一篇的「連線 MongoDB」。
既然是帳戶註冊,那就跟「使用者」的資料息息相關,故我們要建立一個名為 User
的 schema
來定義使用者的資料結構。我們在 src/common/models
下新增一個名為 user.model.ts
的檔案,並將使用者的 schema
、Document
、schema
實體 與 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,
};
可以看到共設計了三個欄位,分別為:username
、email
與 password
,其中,password
為巢狀結構,原因是我們不希望密碼直接儲存在資料庫裡面,而是透過密碼學中的加鹽來替密碼進行加密。
鹽加密經常用在密碼管理,它的概念很簡單,就是將 輸入值(input) 與 某個特定的值(salt) 進行加密,最後會得出一個 結果(hash),只要將 salt
與 hash
存入資料庫就可以避免把原始密碼直接儲存的問題,不過為什麼是儲存這兩個值呢?這就要解釋一下解密的原理了,使用者在登入的時候,會提供我們 username
與 password
這兩個值,這時候我們就要用使用者提供的 username
去找出對應的使用者資料,如果有找到的話就要來驗證 password
的值是否正確,我們只要用 password
與 salt
再進行一次加密,並用計算出來的值跟 hash
做比對,如果完全相同就表示這個使用者提供的密碼與當初在註冊時提供的密碼是相同的。
我們來實作一個共用的方法來處理鹽加密,在 src/core/utils
下新增一個 common.utility.ts
檔案,並設計一個靜態方法 encryptBySalt
,它有兩個參數:input
與 salt
,其中,salt
的預設值為 randomBytes
計算出來的值,而 input
與 salt
透過 pbkdf2Sync
進行 SHA-256 加密並迭代 1000
次,最終返回 hash
與 salt
:
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 了,我們會需要建立兩個模組:UserModule
與 AuthModule
,UserModule
是用來處理與使用者相關的操作,而 AuthModule
則是處理與身分驗證有關的操作,基本上 AuthModule
必定與 UserModule
產生依賴,因為要有使用者才有辦法做身分驗證!
透過 CLI 在 src/features
下產生 UserModule
與 UserService
:
$ 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
下產生 AuthModule
與 AuthController
:
$ 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,並調用 UserService
的 createUser(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 進行測試:
帳戶驗證與登入息息相關,在登入的過程中,會進行一些帳號密碼的檢測,檢測通過之後便完成登入程序。本地帳戶登入可以使用 passport-local
這個 strategy
與 passport
進行搭配,透過 npm
進行安裝即可:
$ npm install passport-local
$ npm install @types/passport-local -D
首先,我們需要先在 UserService
添加一個 findUser
方法來取得使用者資料,用途是讓使用者輸入 username
與 password
後,可以去資料庫中尋找對應的使用者:
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
,在這個檔案中實作一個 LocalStrategy
的 class
,需特別注意的是該 class
要繼承 passport-local
的 strategy
,但需要透過 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 {}
實作完 stragegy
以後,就要實作一個 API 來處理登入驗證,我們在 AuthController
添加一個 signin
方法並套用 AuthGuard
,因為我們是使用 passport-local
這個 strategy
,所以要在 AuthGuard
帶入 local
這個字串,passport
會自動與 LocalStrategy
進行串接,然後 passport
會將 LocalStrategy
中 validate
方法回傳的值寫入 請求物件 的 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 進行測試,會順利得到對應的使用者資料:
今天我們實作了註冊與登入的功能並且初步了解到 passport
在 Nest 中如何使用,不過這些都只算是 登入前 的處理,還有 登入後 要怎麼保持授權狀態等步驟要處理,這部分就留到下篇跟各位詳細說明吧!這裡附上今天的懶人包:
passport
採用策略模式來管理各種帳戶驗證的方式。passport
需要搭配 strategy
來完成完整的帳戶驗證程序。AuthGuard
作為 Guard 並指定要使用哪個 strategy
進行驗證。passport-local
來處理本地帳戶登入的驗證。