全端網站設計範例:後端登入驗證機制

本篇為「全端網站架構」中的後端範例及細節。需先建立專案至後端伺服器啟動(請參閱上一篇:全端網站設計範例:後端新增 Apollo server)。

登入機制的實做會用 JWT Token 儲存在瀏覽器 LocalStorage 的方式實做,不會在後端儲存使用者的 Session。雖然這種做法無法在後端撤銷特定使用者的登入 Token,但足夠輕量且開發較為容易。

登入、登出、驗證流程

登入時,前端會送出 login 的 Request,後端驗證帳號密碼成功後,會產生一個 JWT Token,包含一個 user id 及 iat(Issued at),將此 Token 送回前端並儲存在瀏覽器的 LocalStorage 中,每次送其它 Request 時,前端都需要讀取此 Token,附在 Header 中供後端驗證。

登出時,後端不參與,由前端將儲存在 LocalStorage 中的 JWT Token 移除即完成登出。

如何確保使用者登出成功

以目前設計的機制來看,答案是無法確保使用者登出成功,因為只要被發予的那支 JWT Token 還存在著,任何人都能用其來保持登入狀態,這也是後端不保存 Session 的缺點之一。

因此為了保持一定的安全性,JWT Token 的期限盡量不要設定成太長,至少不要是永久。

定義相關 GraphQL Schema 及 Interface

src/schemas/general.graphql 中加入以下 schemas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Query {
me: User!
}

type Mutation {
login(username: String!, password: String!): AuthPayload!
}

type AuthPayload {
me: User!
token: String!
}

type User {
id: ID!
username: String!
name: String!
}

在 Mutation 中的 login,在輸入帳號密碼後會回覆一個 AuthPayload,包含一個 JWT Token 和此使用者自己(Me)的資訊。
而 Query me 則會查看 Header 所攜帶的 JWT Token,回覆此使用者的資訊。

由於專案用到 TypeScript,我們先將比較容易被重複使用的 Interface 宣告,以便之後在不同檔案 import 使用,在 src 下建立一個檔案叫 interfaces.ts

1
2
3
4
5
6
7
8
9
10
export interface User {
id: string;
username: string;
name: string;
}

export interface AuthPayload {
me: User;
token: string;
}

把 User 和 AuthPayload 宣告並 export 出來。

JWT Token 產生及解析

後端要驗證送過來的 Request 是否包含合法的 JWT Token,需要去解析 Header 裡所挾帶的內容。這邊參考 Apollo Server 官方的 Authentication and authorization

我們要實做一個驗證 JWT Token 是否合法(符合格式、未過期)的小工具,並從 JWT Token 中取出 user id,藉此獲得使用者資訊。
但在此之前,我們需要先產生出 JWT Token。

產生 JWT Token

建立一個新資料夾 src/utils,這裡面會存放一些工具類的 functions,例如我們現在要做的 JWT Token 產生及解析。接著在此資料夾內新增 auth.ts

安裝一些工具,用來 Hash 密碼及產生、解析 JWT Token

1
2
$ npm install bcryptjs jsonwebtoken
$ npm install -D @types/bcryptjs @types/jsonwebtoken

再來加入以下內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { User, AuthPayload } from '../types';

const JWT_SECRET = 'Secret String';
const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 7;

export const login = (username: string, password: string): AuthPayload => {
if (username !== 'admin') throw new Error('User not found');
if (!bcrypt.compareSync(password, '$2a$10$3CVYEA2IMpL.sriYZ3.KZO/OHecjcJJeP9idvytRAygv3FUwTmIWi')) {
throw new Error('Password incorrect');
}
const token = jwt.sign({ id: '1', iat: Math.floor(Date.now() / 1000) }, JWT_SECRET);
return { me: { id: '1', username: 'admin', name: 'Admin' }, token };
};

這個 function 用來登入,由於尚未建立資料庫的關係,先假設資料庫只有一位使用者,帳號密碼分別為 admin / admin。將傳入的密碼與 admin hash 過後的結果比對,正確的話就產生代有現在時間的 JWT Token 和使用者資訊。

驗證 JWT Token

新增以下 function 在 auth.ts

1
2
3
4
5
6
7
8
9
export const getUser = (token: string): User => {
try {
const { iat } = jwt.verify(token.replace('Bearer ', ''), JWT_SECRET) as jwt.JwtPayload;
if (iat <= Math.floor(Date.now() / 1000) - JWT_EXPIRY_SECONDS) return null;
return { id: '1', username: 'admin', name: 'Admin' };
} catch (error) {
return null;
}
};

驗證 JWT Token 這個動作會發生在每次的 Request 中,function 判斷此 Token 是否過期,沒有的話就回傳現在的使用者,有的話就回傳 Null。同樣,由於還沒建立資料庫,先假設只有一位 User。

解析 Header

index.ts 所建立的 Apollo Server 的 startStandaloneServer 中,可以傳入 context 變數,其值需要一個 async function,function 的變數包含 req 這個變數,會將從前端來的 Request 內容帶進來,包含 Header。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { getUser } from './utils/auth';

(async () => {
const { url } = await startStandaloneServer(server, {
listen: { port: 5000 },
context: async ({ req }) => {
const token = req.headers.authorization || '';
const user = getUser(token);
return { user };
},
});
log.info('index', `Server is running at ${url}`);
})();

在 context function 中所 return 的 object 會在接下來要實做的 resolvers 中可以被取得,上面的程式碼處理的事就是將 Header 裡的 Bearer Token 撈出來傳入我們剛剛寫好的 getUser() function,然後拿到使用者資訊給 resolver 使用。

實做相關 Resolvers

src/resolvers/index.ts 中加入以下程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
import { login } from '../utils/auth';
import { User, AuthPayload } from '../types';

export default {
Query: {
me: (_, __, { user }: { user: User }): User => user,
},
Mutation: {
login: (_, { username, password }: { username: string; password: string }): AuthPayload => {
return login(username, password);
},
},
};

參考資料

  1. Authentication and authorization

CHANGELOG

日期 敘述
2024-03-04 更新使用的工具版本、建立專案細節
2021-08-24 初版