Skip to main content

(Google) Authentication with Passport

這篇這篇 有實作了 passport-local + jwt 來完成登入以及持續驗證, 接下來實作用第三方服務來登入 (jwt 的部分同前一篇), 這邊以 google 為例


前置作業

在串接 passport-google 之前, 需要先去 Google Cloud Platform (GCP) 啟用 Google Oauth 服務, 並建立憑證取得 clientIDclientSecret, 以及設定 callback url, 這邊不贅述, 可參考 Google 文件

Passport-google 流程

  1. 使用者點擊登入按鈕進入我們 server controller 中定義的 route, 並透過 passportstrategy 重定向至 Google 登入頁面
  2. 使用者在 Google 登入後, 會被重定向至我們 server 中定義的 callback url, 並帶有 Google 回傳的資訊
  3. server 端再透過 passportstrategy 來解析 Google 回傳的資訊, 並進行後續(成功或失敗)的處理

建立 Guard 及 Strategy

先安裝所需套件

pnpm add passport-google-oauth20 --filter PACKAGE_NAME
pnpm add -D @types/passport-google-oauth20 --filter PACKAGE_NAME

建立 Guard

google.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Response } from 'express';
import { isEmpty } from 'lodash';

@Injectable()
export class GoogleOauthGuard extends AuthGuard('google') {
constructor(private readonly configService: ConfigService) {
super();
}

async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}

async getAuthenticateOptions(context: ExecutionContext) {
// you can get access to the request object.
const req = context.switchToHttp().getRequest();
// const referer = req.headers.referer;
// let state = req.query.state;
// if (state) {
// try {
// state = JSON.parse(decodeURIComponent(state));
// } catch (error) {
// console.error('Error parsing state: ', error);
// }
//
// state.referer = referer;
// req.query.state = JSON.stringify(state);
// }

return req.query;
}

handleRequest(error: any, user: any, info: any, context: ExecutionContext) {
if (error || !isEmpty(info)) {
const req = context.switchToHttp().getRequest();
const res: Response = context.switchToHttp().getResponse<Response>();
const originalUrl = `${this.configService.get<string>('general.selfUrl')}/signin`;
const queryString = req.originalUrl.split('?')[1] || '';
const redirectUrl = queryString !== '' ? `${originalUrl}?${queryString}` : originalUrl;

if (error) {
return res.redirect(`${redirectUrl}&error=${encodeURIComponent(JSON.stringify({ message: error.response.message }))}`);
}

return res.redirect(`${redirectUrl}&error=${encodeURIComponent(JSON.stringify({ message: info }))}`);
}
return user;
}
}

與 passport-local 相同的部分可以參考 passport-local

getAuthenticateOptions 方法的主要用途是讓你能夠在 AuthGuard 被執行時動態地提供額外的驗證選項或參數, 其回傳值會與 AuthGuard 構造函數中傳遞的預設(默認)選項合併成一個完整的選項集, 然後再傳遞給 passport.authenticate().

這樣的好處是在某些情況下, 你需要根據請求的具體內容來動態生成驗證選項, ex: Facebook/Google 允許你傳遞一個狀態參數 (state) 來維持 request 與 callback 之間的狀態, 這個狀態訊息通常是基於當前的請求內容而動態生成的 (relative issue)

handleRequest 這邊可以處理登入失敗後, 將瀏覽器導向我們期望的位置

建立 Strategy

google.strategy.ts
// ...
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request as ExpressRequest } from 'express';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';

type GoogleJson = {
sub: string;
name: string | undefined;
given_name: string | undefined;
email: string;
picture: string | undefined;
email_verified?: boolean;
};

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private readonly cryptUtility: CryptUtility,
private readonly configService: ConfigService,
private readonly passportService: PassportService,
private readonly userService: UserService
) {
super({
clientID: configService.get<string>('auth.authGoogleClientID'),
clientSecret: configService.get<string>('auth.authGoogleClientSecret'),
callbackURL: configService.get<string>('auth.authGoogleCallbackURL'),
scope: ['email', 'profile', 'openid'],
accessType: 'offline',
passReqToCallback: true
});
}

// pass options to superclass auth call by overriding superclass method that will be passed to authenticate()
authorizationParams(): { [key: string]: string } {
return {
access_type: 'offline',
prompt: 'consent' // 提示使用者表示同意
};
}

async validate(req: ExpressRequest, _accessToken: string, _refreshToken: string, profile: any, done: VerifyCallback): Promise<any> {
const { id } = profile;
const _json: GoogleJson = profile._json;
let user: null | Partial<UserDocument>;

// do something with the profile

done(null, user);
}
}

clientID, clientSecret, callbackURL 就是一開始在 GCP 建立的 OAuth 資訊, scope 是要取得的資料, passReqToCallback 決定是否將 request 傳遞給 callback function.

至於 accessType 以及搭配下面的 authorizationParams() 是為了在已經授權過後仍能取得 refresh token, 詳細可以參考 保哥這篇

validate 在驗證成功後可以處理取得的資訊, 這邊可以決定是否要直接回傳 user 或是透過自己的一些邏輯去針對 user 資料搭配 db 去做存取

設定 Controller

auth.controller.ts
// ...
@Controller('auth')
export class AuthController {
constructor() // ...
{}
// ...

@UseGuards(GoogleOauthGuard)
@Get('google')
googleSign() {}

@UseGuards(GoogleOauthGuard)
@Get('google/callback')
async googleAuthCallback(@Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse) {
const url = await this.parseUtility.parseStateFromThirdPartyOauth(req.query.state as string);
// ...
url.search = new URLSearchParams([...Array.from(url.searchParams.entries()), ['st', req.session.uniqueId.toString()]]).toString();

res.redirect(`${url}`);
}
}

透過請求 [GET] /google 重定向至 Google 登入頁面, 並透過 [GET] /google/callback 處理 Google 登入後的 callback, 當失敗的時候我們已經有在前面的 validate 處理了錯誤的 redirect, 這邊則處理成功的 case

最後將 Strategy 加入 Module

cas.module.ts
// ...
@Module({
imports: [
// ...
PassportModule.register({ session: true })
],
providers: [
// ...
GoogleStrategy,
SessionSerializer
],
exports: [AuthService, UserService],
controllers: [AuthController, UserController]
})
export class CasModule {}

前端 cas-client 的 signin 部分主要是實作一個 Signin button:

<div className="mt-3 flex items-center justify-center space-x-2">
<Button
text="Sign in with Google"
icon={<LogoGoogleSvg />}
className={`hover:bg-basic-content-5 border-basic-content-10 hover:border-content-10 text-basic-content-70 hover:text-basic-content w-fit border border-solid bg-transparent`}
onClick={(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
const params = new URLSearchParams(search);
const obj: {
[key: string]: string;
} = {};

for (const [key, value] of params.entries()) {
obj[key] = value;
}
const state = encodeURIComponent(JSON.stringify({ params: obj }));
window.open(`${apiUrl}/YOUR_PATH/google?state=${state}`, '_self');
}}
/>
</div>

其他的驗證或存取可參考 前一篇, web 前端的實作可參考寫在 ReactJs 中 State ManagementNestJs Guard use case