
在上一篇中,我們建立的簡單的 sqlite 作為接下來要用 session 的基礎設施
以及我們也為密碼雜湊以及驗證做簡單的準備,接下來我們要更近一步把查見的基礎設施建立起來
今天我們要先做 jwt 的驗證與產生,再來製作我們 Refresh token 的部分
我個人推薦使用 jwt 的庫 jose 主要是目前 js/ts 生態系中,目前對於他的相關支援是比較完整的
至於簽章邏輯,可以避免 timing attack(時間差攻擊),這部分我們就不打算手刻來做了
安裝 :
bun add jose
src/jwt.ts
順便一提,如果單一服務我建議 HS256 的實作
如果微服務/多服務架構,建議用 RS256 的實作架構 -> 非對稱金鑰,簽發端與驗證端分離
那因為我們的實作沒有要搞這麼複雜,先以 HS256 為主
那 jose 提供 jwt 的部分相當簡單,SignJWT 以及 jwtVerify
檔案為 src/jwt.ts
import { SignJWT, jwtVerify } from 'jose'
const accessTokenSecret = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET);
const accessTokenTTL = '15m' // 我們設個 15 分鐘的有效時間
const issuer = 'auth-service';
export interface AccessTokenPayload {
sub: string
email: string
}
const isAccessTokenPayloadResult = (input: unknown): input is AccessTokenPayload => {
if (!(input instanceof Object))
return false;
if (!('sub' in input) || !('email' in input))
return false;
const { sub, email } = input;
return typeof sub === 'string' && typeof email === 'string';
}
export const signAccessToken = async (payload: AccessTokenPayload): Promise<string> => {
const { email } = payload;
return new SignJWT({ email })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setSubject(payload.sub)
.setIssuedAt()
.setExpirationTime(accessTokenTTL)
.setIssuer(issuer)
.sign(accessTokenSecret);
}
export const verifyAccessToken = async (token: string): Promise<AccessTokenPayload> => {
const { payload } = await jwtVerify(token, accessTokenSecret, {
issuer,
})
if (!isAccessTokenPayloadResult(payload))
throw new Error('not payload format');
return payload;
}
我們簡單測試一下結果

關於 secret key 可能會有人想說隨便設就好,是這樣沒錯
不過個人不知道怎麼設定時通常會用指令
像是
openssl rand -hex 32
或是
openssl rand -base64 32
基本上就可以隨機產生一個
我們這部分需要做主動撤銷的功能
這裡我們引用前一天我們做的 sqlite 程式碼在這裡做使用(./src/db.ts)
我們把檔案建立在 ./src/session.ts
通常我們 Refresh token TTL 會設定長一點,我們這就設定個 30 天
const refreshTokenTTL = 30 * 24 * 60 * 60 * 1000; // 這樣 30 天
我們完整把相關方法都實作出來
import { db } from './db';
type Nullable<T> = T | null;
type Option<T> = T | undefined;
export interface SessionMeta {
userAgent?: string
ip?: string
}
export interface SessionIdObject {
id: string
user_id: string
}
export interface RotationSessionResult {
userId: string
newRawToken: string
}
const refreshTokenTTL = 30 * 24 * 60 * 60 * 1000; // 這樣 30 天
const isSessionIdObjectOrNone = (input: unknown): input is Option<SessionIdObject> => {
if (typeof input === 'undefined')
return true;
if (!(input instanceof Object))
return false;
if (!('id' in input) || !('user_id' in input))
return false;
const { id, user_id } = input;
return typeof id === 'string' && typeof user_id === 'string';
}
// 雜湊 token
const hashToken = (token: string): string => {
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(token);
return hasher.digest('hex');
}
// 創建 session 的行為
export const createSession = (userID: string, meta: SessionMeta): string => {
const rawToken = crypto.randomUUID()
const tokenHash = hashToken(rawToken);
const now = Date.now();
// 這個只是很樸素的sql 操作
db.query(`
INSERT INTO sessions
(id, user_id, user_agent, ip_address, expires_at, created_at)
VALUES
(?,?,?,?,?,?)
`).run(
tokenHash,
userID,
meta.userAgent ?? null,
meta.ip ?? null,
now + refreshTokenTTL,
now,
)
return rawToken;
}
// 驗證 session
export const validateSession = (rawToken: string): Nullable<SessionIdObject> => {
const tokenHash = hashToken(rawToken)
const row = db.query(`
SELECT * FROM sessions WHERE id = ? AND revoked_at IS NULL AND expires_at > ?
`).get(tokenHash, Date.now())
return isSessionIdObjectOrNone(row) ? row ?? null : null;
}
// 撤銷 session
export const revokeSession = (rawToken: string): boolean => {
const tokenHash = hashToken(rawToken);
db.query(`
UPDATE sessions SET revoked_at = ? WHERE id = ?
`).run(Date.now(), tokenHash)
return true;
}
// 替換 refresh token
export const rotateSession = (oldRawToken: string, meta: SessionMeta): Nullable<RotationSessionResult> => {
const session = validateSession(oldRawToken);
if (!session)
return null;
const isRevokeSuccess = revokeSession(oldRawToken);
if (!isRevokeSuccess)
throw new Error('怎麼會撤銷失敗!')
return {
userId: session.user_id,
newRawToken: createSession(session.user_id, meta)
}
}
// 撤銷對應 user_id 的 token
export const revokeSessionWithUser = (userId: string): boolean => {
db.query(`
UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL
`).run(Date.now(), userId)
return true;
}
我們來看看結果

那我這裡寫了一大坨程式,可能需需要練習的人需要,我這裡放在 github 上面
到這邊,我們把今天的兩個核心基礎設施都補齊了:
接下來一篇,我們要針對認證系統實作做收尾了