Skip to main content

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.

1.png

搭配 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 方法.