(Google) Authentication with Passport
在 這篇 及 這篇 有實作了 passport-local + jwt 來完成登入以及持續驗證, 接下來實作用第三方服務來登入 (jwt 的部分同前一篇), 這邊以 google 為例
前置作業
在串接 passport-google 之前, 需要先去 Google Cloud Platform (GCP) 啟用 Google Oauth 服務, 並建立憑證取得 clientID
和 clientSecret
, 以及設定 callback url
, 這邊不贅述, 可參考 Google 文件
Passport-google 流程
- 使用者點擊登入按鈕進入我們 server controller 中定義的 route, 並透過
passport
的strategy
重定向至 Google 登入頁面 - 使用者在 Google 登入後, 會被重定向至我們 server 中定義的
callback url
, 並帶有 Google 回傳的資訊 - server 端再透過
passport
的strategy
來解析 Google 回傳的資訊, 並進行後續(成功或失敗)的處理
建立 Guard 及 Strategy
先安裝所需套件
pnpm add passport-google-oauth20 --filter PACKAGE_NAME
pnpm add -D @types/passport-google-oauth20 --filter PACKAGE_NAME
建立 Guard
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
// ...
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
// ...
@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
// ...
@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 Management
的 NestJs Guard use case