前一篇完成了註冊機制,但在註冊完成時,應該要有個媒介讓我們能夠使用該帳戶,以該帳戶的名義進行操作,而不是取得整個帳戶資料,那要如何產生所謂的媒介又同時享有該帳戶的基本資訊呢?這時候 JsonWebToken (JWT) 會是很好的選擇。
JWT 是一種新的 token 設計方法,在 token 中附帶使用者資訊,降低伺服器請求資料庫的頻率,不過在JWT 中不建議存放敏感資訊,例如:信用卡卡號、身分證字號等,可以存放ID、使用者名稱等資訊,為什麼呢?因為存放的資訊並 無加密 ,而是以 Base64 的方式呈現。
一個標準的 JWT 格式如下:
ew0KICAiYWxnIjogIkhTMjU2IiwNCiAgInR5cCI6ICJKV1QiDQp9.ew0KICAiaWQiOiAiMSIsDQogICJ1c2VybmFtZSI6ICJIQU8iLA0KICAiZW1haWwiOiAiaGVsbG93d29ybGRAZXhhbXBsZS5jb20iDQp9.T468GEOrM7E2cBab30R4681FVEfxjoYZg14DAS7HgQ
仔細看這串字有 .
做為分隔,共切成三個部分:
第一段「ew0KICAiYWxnIjogIkhTMjU2IiwNCiAgInR5cCI6ICJKV1QiDQp9」為 標頭,內容為「加密方式」與「定義類型」,下方為解碼後的內容:
{
"alg": "HS256",
"typ": "JWT"
}
第二段「ew0KICAiaWQiOiAiMSIsDQogICJ1c2VybmFtZSI6ICJIQU8iLA0KICAiZW1haWwiOiAiaGVsbG93d29ybGRAZXhhbXBsZS5jb20iDQp9」為 內容,用來存放使用者的基本資訊,下方為解碼後的內容:
{
"id": "1",
"username": "HAO",
"email": "hellowworld@example.com"
}
第三段「T468GEOrM7E2cBab30R4681FVEfxjoYZg14DAS7HgQ」為 簽章,用來驗證是否遭竄改。
先安裝 JWT 的套件:
npm install jsonwebtoken
以及定義檔:
npm install @types/jsonwebtoken --save-dev
由於 JWT 需要使用一組 key 進行簽章,所以添加至環境變數以方便取用:
JWT_SIGN=YOUR_JWT_SIGN
在 LocalAuthService
中匯入 jsonwebtoken:
import JWT from 'jsonwebtoken';
並新增 generateJWT
方法來產生合法的 JWT,這邊我設定有效期為 7 天:
public generateJWT(user: LocalAuthDocument): string {
const expiry = new Date();
expiry.setDate(expiry.getDate() + 7);
return JWT.sign({
_id: user._id,
username: user.username,
exp: expiry.getTime() / 1000
}, (process.env.JWT_SIGN as string));
}
現在要讓 LocalAuthController
中的 signup
回傳 JWT,所以要進行修改:
public async signup(req: Request): Promise<ResponseObject> {
const { username, password, email } = req.body;
const user = await this.localAuthSvc.addUser(username, password, email);
const token = this.localAuthSvc.generateJWT(user);
return this.formatResponse(token, HttpStatus.CREATED);
}
透過 Postman 再註冊一次,會看到 data
為 JWT:
登入驗證可以很簡單也可以很複雜,如果今天只使用本地帳戶的方式,那恭喜你非常幸福,但如果今天有本地帳戶、Google、Facebook 等登入方式的話,就會變得較為複雜,這時候可以用一些工具來輔助我們,使流程變得較有一致性,在管理上會輕鬆許多,那是哪個工具呢?就是很熱門的 passport。
passport 是一個帳戶驗證機制的熱門套件,它提供上百種驗證機制,像是:本地帳戶驗證、OAuth 的驗證等,比較特別的是它將 驗證流程 與 驗證機制 分離, passport 本身只處理流程 ,透過其 策略(Strategy) 套件來完成驗證機制,如下圖所示:
透過 npm 進行安裝:
npm install passport
安裝定義檔:
npm install @types/passport --save-dev
passport 是中介軟體,透過初始化在 Request 物件中添加相關配置,讓其他中介軟體得以使用,所以要在 App
中新增配置:
import passport from 'passport';
private setPassport(): void {
passport.initialize();
}
並在 contructor
中使用:
constructor() {
this.setEnvironment();
this.setHelmet();
this.setCors();
this.setPassport();
this.registerRoute();
}
passport 提供的驗證策略非常多,我們是以本地帳號作為開發範例,若對串接 Google 帳戶等有興趣,在看完本篇有了基本概念之後,再去學習會較容易喔!我們先安裝本地帳號驗證的策略:
npm install passport-local
安裝定義檔:
npm install @types/passport-local --save-dev
在 local-auth.service.ts
中匯入需要用的模組:
import passport from 'passport';
import { Strategy, VerifyFunction } from 'passport-local';
在 LocalAuthService
中設計一個 getter 來取得策略:
public get Strategy() {
return new Strategy(
{ session: false },
this.verifyUserFlow()
);
}
session 為是否啟用 session 的配置項目,因為我們用 JWT,就不啟用 session 囉
這時候會看到 verifyUserFlow()
這個方法,就是策略要執行的驗證函式:
private verifyUserFlow(): VerifyFunction {
return (username: string, password: string, done) => {
this.localAuthRepo.getUser({ username })
.then(user => {
const error = new Error();
if ( !user ) {
error.message = '查無此用戶';
(error as any).status = HttpStatus.NOT_FOUND;
return done(error);
}
if ( !this.verifyPassword(user, password) ) {
error.message = '您輸入的密碼有誤';
(error as any).status = HttpStatus.FORBIDDEN;
return done(error);
}
return done(null, user);
})
.catch((err: Error) => done(err));
}
}
上方出現的 verifyPassword
為驗證密碼的方法:
private verifyPassword(user: LocalAuthDocument, password: string): boolean {
const pair = this.localAuthRepo.hashPassword(password, user.password.salt);
return pair.hash === user.password.hash;
}
驗證函式前兩個參數即 username
與 password
,第三個參數為結束驗證流程用的函式 done
,它採用 Error-First 的方式,所以 done
的第一個參數要傳入 錯誤資訊 ,第二個為 user 資訊,第三個為自訂選項。
透過 passport 的 authenticate
方法處理走完驗證流程的結果,由於是採用本地帳戶的策略,所以要指定策略為 local
,又因為 authenticate
是採用 callback 的方式而非 Promise,所以我們將它包在 Promise 中使用:
public authenticate(...args: any[]): Promise<string> {
return new Promise((resolve, reject) => {
passport.authenticate('local', (err: Error, user: LocalAuthDocument) => {
if ( err ) {
return reject(err);
}
const token = this.generateJWT(user);
resolve(token);
})(...args);
});
}
可以看到傳入參數為 args
,主要是把 Request
、Response
與 NextFunction
帶入 authenticate
中。
策略規劃完後就要將它與 Controller 做連結,所以在 LocalAuthController
新增 signin
方法並透過 passport 的 use
套用策略,接著再等待 authenticate
的結果:
public async signin(
req: Request,
res: Response,
next: NextFunction
): Promise<ResponseObject> {
passport.use(this.localAuthSvc.Strategy);
const token = await this.localAuthSvc.authenticate(req, res, next);
return this.formatResponse(token, HttpStatus.OK);
}
最後就是新增路由,修改 LocalAuthRoute
:
protected registerRoute(): void {
this.router.post(
'/signup',
express.json(),
this.responseHandler(this.controller.signup)
);
this.router.post(
'/signin',
express.json(),
this.responseHandler(this.controller.signin)
);
}
透過 Postman 進行登入,下圖分別為登入成功與失敗:
今天的內容完整實現了登入驗證機制與 JWT,對於第一次接觸 passport 的人可能會比較難懂它的運作流程,所以我這邊整理了一份本地帳戶的驗證流程圖:
最後還有一個部分要處理,就是當使用者要新增 Todo 的時候,應該要帶著 JWT 到後端才能確認身份,但現在不管有沒有帶 JWT 都可以新增 Todo,這時候我們就必須實作另一個很重要的概念 - Guard。