Guard
Guard 是一個 @Injectable()
annotation 的 Class, 並實作了 CanActivate
interface. Guard 可以用來保護 Route, 類似於 Middleware, 它執行在所有 Middleware
之後, 並執行於 任何 Interceptor or Pipe
之前.
基本使用
建立 Guard
nest g guard auth
auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
// const request = context.switchToHttp().getRequest();
// return validateRequest(request);
// 可以直接在這邊 reject request
return false;
}
}
- 每個 Guard 都必須實作
canActivate
方法, 並回傳boolean
,Promise<boolean>
或Observable<boolean>
來決定是否允許進入 Route canActivate()
提供一個ExecutionContext
, 繼承自ArgumentsHost
(同之前 exception-filter 提到的, 或可參考官方文件)canActivate()
可以使用 async/await, 並且當要reject
的時候可以直接透過 throw HTTP exception 的方式來回傳自訂的錯誤訊息
使用 Guard
app.controller.ts
// ...
@Controller('auth')
export class AuthController {
// ...
@UseGuards(AuthGuard)
@Post('/signin')
signin(@Body() _user: SigninDto) {
return this.authService.signin(_user);
}
}
可以看到因為上面 AuthGuard 回傳 false
, 所以會直接回傳 Forbidden
response.
搭配 Passport 使用
Guard 可以與 Passport 一起使用, Passport
是目前很受歡迎的 node.js 認證套件, NestJs 也提供了 @nestjs/passport
來方便整合 Passport. 下面以 passport-local
來實作
安裝 Passport 相關套件
pnpm add @nestjs/passport passport passport-local express-session --filter PACKAGE_NAME
pnpm add -D @types/passport-local --filter PACKAGE_NAME
建立 serialize 與 deserialize
建立 session.serializer.ts
session.serializer.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
@Injectable()
export class SessionSerializer extends PassportSerializer {
serializeUser(user: any, done: (err: Error, user: any) => void): any {
done(null, user);
}
deserializeUser(payload: any, done: (err: Error, payload: string) => void): any {
done(null, payload);
}
}
SessionSerializer
用於在使用 session 的情況下對用戶資料進行 serialize 和 deserialize, 這些操作主要是用來在每個請求之間保持用戶的登入狀態
在使用 passport 進行認證時, 當用戶成功登入後 passport 會將用戶的資訊儲存在 session 中, 需要將用戶物件 serialize 為 session 可儲存的格式, 而當後續請求到來時, passport 可以從 session 中 deserialize 出 user object, 以便能 夠繼續使用這些資訊進行驗證或授權
這邊我們僅使用來自身份驗證提供者的使用者資料 (第三方服務那幾篇範例亦同, 後續的授權或存取則由自身專案來處理相關邏輯), 因此我們可以按照初始的序列化使用方式即可
在 app.module.ts 中加入 PassportModule 與 SessionSerializer
app.module.ts
// ...
import { PassportModule } from '@nestjs/passport';
import { SessionSerializer } from '@/core/guards/passport/session.serializer';
@Module({
imports: [
// ...
PassportModule.register({ session: true }) // enable session
],
providers: [
// ...
SessionSerializer
],
exports: [AuthService, UserService],
controllers: [AuthController, UserController]
})
export class CasModule {}
在 main.ts 中啟用 session
main.ts
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import connectMongo from 'connect-mongodb-session';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import passport from 'passport';
import { AppModule } from './app.module';
const MongoDBStore = connectMongo(session);
async function bootstrap() {
// ...
const sessionMap: session.SessionOptions = {
resave: false,
saveUninitialized: false,
secret: sessionSecret,
proxy: true,
name: sessionName,
cookie: {
sameSite: 'lax',
secure: true,
// TODO: set maxAge to X minutes later
maxAge: 0.5 * 60 * 1000, // session expire time 30s
domain: sessionDomain
}
};
const store = new MongoDBStore({
uri,
collection: sessionCollection
});
app.set('trust proxy', 1);
app.use(cookieParser());
app.use(
session({
store: store,
...sessionMap
})
);
app.use(passport.initialize());
app.use(passport.session());
// ...
await app.listen(port);
}
bootstrap();
建立 passport-local.guard.ts 與 local.strategy.ts
passport-local.guard.ts
import { BadRequestException, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import providerToken from '@/common/exceptions/constants/provider-token';
import { handleExceptionHelper, handleValidationErrorHelper } from '@/common/exceptions/utils/exception.utility';
@Injectable()
export class PassportLocalGuard extends AuthGuard('local') {
constructor() {
super();
}
async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}
// if you want to handle the something before the request is passed to the route handler
handleRequest(error: any, user: any, info: any, context: ExecutionContext) {
if (error || !user) {
console.log('order 1, if is error');
if (error) {
throw error;
}
return handleValidationErrorHelper({
error: {
statusCode: providerToken.SIGNIN_VALIDATION_FAILED,
message: info
},
HttpExceptionInstance: BadRequestException
});
}
console.log('order 2, if is not error');
return user;
}
}
local.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { handleValidationErrorHelper } from '@/common/exceptions/utils/exception.utility';
import { AuthService } from '@/features/cas/auth/auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: 'email' }); // default is `username`, but we can change it
}
async validate(email: string, pwd: string) {
console.log('order 1, if strategy passed');
const { user } = await this.authService.validateUser(email, pwd);
if (!user) {
return throwValidationError();
}
return user;
}
}
- NestJs 透過
AuthGuard
來包裝 passport 的Strategy
機制, 所以AuthGuard('local')
會使用passport-local
這個 strategy, 並且passport-local
strategy 需要實作validate
方法, 這個方法會在passport-local
strategy 驗證成功後被呼叫. - 透過
Strategy
來做驗證, 在Guard
這邊其實可以不用再寫canActivate
方法, 因為已經在Strategy
實作了validate
方法, 但如果需要使用 session 來保存 user 的登入狀態, 就需在canActivate(context)
(super.canActivate 會調用父類的 canActivate, 也就是AuthGuard
的 canActivate 方法, 這會觸發 Passport 認證流程) 認證成功後調用logIn
方法來將 user 資料儲存在 session 中並觸發 serialize, 這樣在後續的請求中SessionSerializer
才能進行 deserialize 來恢復用戶的登入狀態 - 當驗證
失敗
的時候, 可以透過handleRequest
來處理錯誤訊息, 這個方法會在passport-local
strategy 驗證失敗後被呼叫, 並且不會進入到 LocalStrategy 的validate
方法. - 但當驗證
成功
的時候, 則是會先進入到 LocalStrategy 的validate
方法, 並且會回傳處理後的值, 這時候在handleRequest
會接收到這個值, 此時你也可以在這裡進行額外的邏輯, 然後再進入到Route
或一樣可以做Reject
的動作.
在 Controller 中使用 PassportLocalGuard
auth.controller.ts
// ...
import { PassportLocalGuard } from '@/core/guards/passport/passport-local.guard';
import { SigninDto } from '@/core/pipes/cas/signin/signin.dto';
@Controller('auth')
export class AuthController {
// ...
@UseGuards(PassportLocalGuard)
@Post('/signin')
signin(@Body() _user: SigninDto) {
return this.authService.signin(_user);
}
}
- 最終當
PassportLocalGuard
驗證通過後, 才會執行signin
方法.