經過 Day 24 的 Email 服務建置,我們已經有了完整的使用者溝通管道。今天我們要實作用戶認證系統的後端核心:JWT (JSON Web Token) 與 Session 管理。這是 SaaS 產品安全性的基石,我們將實作 Token 生成、驗證、刷新、以及各種安全策略。
/**
* JWT vs Session-based Authentication
*
* ┌─────────────────┬──────────────────┬──────────────────┐
* │ 特性 │ JWT │ Session │
* ├─────────────────┼──────────────────┼──────────────────┤
* │ 狀態 │ 無狀態(Stateless)│ 有狀態(Stateful) │
* │ 儲存位置 │ 客戶端 │ 伺服器 │
* │ 可擴展性 │ 🟢 優秀 │ 🟡 需額外處理 │
* │ 伺服器負載 │ 🟢 低 │ 🔴 高 │
* │ 撤銷能力 │ 🔴 困難 │ 🟢 容易 │
* │ 跨域 │ 🟢 容易 │ 🟡 需設定 │
* │ Token 大小 │ 🔴 較大(~200B) │ 🟢 小(Session ID)│
* │ 安全性 │ 🟡 需小心處理 │ 🟢 較安全 │
* │ 適用場景 │ 微服務、API │ 傳統 Web App │
* └─────────────────┴──────────────────┴──────────────────┘
*
* 我們的方案:混合式 (Hybrid)
* - Access Token: JWT (短期,15分鐘,存 localStorage)
* - Refresh Token: Opaque Token (長期,7天,存 httpOnly cookie + Redis)
*
* 優點:
* ✅ 兼具 JWT 的無狀態優勢
* ✅ 保留 Session 的撤銷能力
* ✅ 最佳安全性平衡
*/
// packages/kyo-core/src/auth/jwt-service.ts
import { SignJWT, jwtVerify, JWTPayload } from 'jose';
import crypto from 'crypto';
export interface JWTTokenPayload extends JWTPayload {
userId: string;
email: string;
role: 'admin' | 'user' | 'viewer';
tenantId: string;
type: 'access' | 'refresh';
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export class JWTService {
private readonly accessTokenSecret: Uint8Array;
private readonly refreshTokenSecret: Uint8Array;
private readonly issuer: string;
private readonly audience: string;
// Token 有效期
private readonly accessTokenExpiry = '15m'; // 15 分鐘
private readonly refreshTokenExpiry = '7d'; // 7 天
constructor(config: {
accessTokenSecret: string;
refreshTokenSecret: string;
issuer?: string;
audience?: string;
}) {
// 將 secret 轉為 Uint8Array (JOSE 要求)
this.accessTokenSecret = new TextEncoder().encode(config.accessTokenSecret);
this.refreshTokenSecret = new TextEncoder().encode(config.refreshTokenSecret);
this.issuer = config.issuer || 'kyo-auth';
this.audience = config.audience || 'kyo-api';
}
/**
* 生成 Access Token (JWT)
*/
async generateAccessToken(payload: {
userId: string;
email: string;
role: 'admin' | 'user' | 'viewer';
tenantId: string;
}): Promise<string> {
const token = await new SignJWT({
userId: payload.userId,
email: payload.email,
role: payload.role,
tenantId: payload.tenantId,
type: 'access',
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(this.issuer)
.setAudience(this.audience)
.setExpirationTime(this.accessTokenExpiry)
.sign(this.accessTokenSecret);
return token;
}
/**
* 生成 Refresh Token (Opaque Token)
*
* 為什麼不用 JWT?
* - Refresh Token 需要可撤銷
* - 需要追蹤使用記錄
* - 儲存在 Redis,方便管理
*/
generateRefreshToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* 生成 Token 對
*/
async generateTokenPair(payload: {
userId: string;
email: string;
role: 'admin' | 'user' | 'viewer';
tenantId: string;
}): Promise<TokenPair> {
const [accessToken, refreshToken] = await Promise.all([
this.generateAccessToken(payload),
Promise.resolve(this.generateRefreshToken()),
]);
return {
accessToken,
refreshToken,
expiresIn: 15 * 60, // 15 分鐘(秒)
};
}
/**
* 驗證 Access Token
*/
async verifyAccessToken(token: string): Promise<JWTTokenPayload> {
try {
const { payload } = await jwtVerify(token, this.accessTokenSecret, {
issuer: this.issuer,
audience: this.audience,
});
return payload as JWTTokenPayload;
} catch (error: any) {
if (error.code === 'ERR_JWT_EXPIRED') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}
/**
* 解碼 Token (不驗證簽章)
* 用於除錯或取得過期 Token 的資訊
*/
decodeToken(token: string): JWTTokenPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString('utf-8')
);
return payload as JWTTokenPayload;
} catch {
return null;
}
}
/**
* 取得 Token 剩餘有效時間(秒)
*/
getTokenTTL(token: string): number {
const decoded = this.decodeToken(token);
if (!decoded || !decoded.exp) return 0;
const now = Math.floor(Date.now() / 1000);
const ttl = decoded.exp - now;
return Math.max(0, ttl);
}
}
// packages/kyo-core/src/auth/refresh-token-store.ts
import Redis from 'ioredis';
export interface RefreshTokenData {
userId: string;
token: string;
createdAt: Date;
expiresAt: Date;
userAgent?: string;
ipAddress?: string;
deviceId?: string;
}
export class RefreshTokenStore {
private redis: Redis;
private readonly ttl = 7 * 24 * 60 * 60; // 7 天(秒)
constructor(redisClient: Redis) {
this.redis = redisClient;
}
/**
* 儲存 Refresh Token
*/
async storeRefreshToken(data: RefreshTokenData): Promise<void> {
const key = this.getKey(data.userId, data.token);
const value = JSON.stringify({
userId: data.userId,
createdAt: data.createdAt.toISOString(),
expiresAt: data.expiresAt.toISOString(),
userAgent: data.userAgent,
ipAddress: data.ipAddress,
deviceId: data.deviceId,
});
// 儲存 Token
await this.redis.setex(key, this.ttl, value);
// 將 Token 加入用戶的 Token 列表(用於批次撤銷)
const userTokensKey = this.getUserTokensKey(data.userId);
await this.redis.sadd(userTokensKey, data.token);
await this.redis.expire(userTokensKey, this.ttl);
}
/**
* 驗證 Refresh Token
*/
async verifyRefreshToken(
userId: string,
token: string
): Promise<RefreshTokenData | null> {
const key = this.getKey(userId, token);
const value = await this.redis.get(key);
if (!value) {
return null;
}
const data = JSON.parse(value);
// 檢查是否過期
if (new Date(data.expiresAt) < new Date()) {
await this.revokeRefreshToken(userId, token);
return null;
}
return {
userId: data.userId,
token,
createdAt: new Date(data.createdAt),
expiresAt: new Date(data.expiresAt),
userAgent: data.userAgent,
ipAddress: data.ipAddress,
deviceId: data.deviceId,
};
}
/**
* 撤銷單一 Refresh Token
*/
async revokeRefreshToken(userId: string, token: string): Promise<void> {
const key = this.getKey(userId, token);
await this.redis.del(key);
// 從用戶 Token 列表移除
const userTokensKey = this.getUserTokensKey(userId);
await this.redis.srem(userTokensKey, token);
}
/**
* 撤銷用戶所有 Refresh Token(登出所有裝置)
*/
async revokeAllUserTokens(userId: string): Promise<void> {
const userTokensKey = this.getUserTokensKey(userId);
const tokens = await this.redis.smembers(userTokensKey);
if (tokens.length === 0) return;
// 刪除所有 Token
const pipeline = this.redis.pipeline();
tokens.forEach((token) => {
pipeline.del(this.getKey(userId, token));
});
pipeline.del(userTokensKey);
await pipeline.exec();
}
/**
* 取得用戶所有活躍的 Refresh Token
*/
async getUserTokens(userId: string): Promise<RefreshTokenData[]> {
const userTokensKey = this.getUserTokensKey(userId);
const tokens = await this.redis.smembers(userTokensKey);
const results = await Promise.all(
tokens.map((token) => this.verifyRefreshToken(userId, token))
);
return results.filter((r): r is RefreshTokenData => r !== null);
}
/**
* 清理過期 Token(定期任務)
*/
async cleanupExpiredTokens(): Promise<number> {
// Redis 的 SETEX 會自動過期,這裡主要是清理 userTokens set
// 實際上 Redis 的過期機制已經處理了大部分清理工作
return 0; // 由 Redis 自動處理
}
/**
* Token Rotation: 使用後立即撤銷舊 Token,發放新 Token
*/
async rotateRefreshToken(
userId: string,
oldToken: string,
newTokenData: RefreshTokenData
): Promise<void> {
// 撤銷舊 Token
await this.revokeRefreshToken(userId, oldToken);
// 儲存新 Token
await this.storeRefreshToken(newTokenData);
}
private getKey(userId: string, token: string): string {
return `refresh_token:${userId}:${token}`;
}
private getUserTokensKey(userId: string): string {
return `user_refresh_tokens:${userId}`;
}
}
// packages/kyo-core/src/auth/auth-service.ts
import bcrypt from 'bcrypt';
import { JWTService, TokenPair } from './jwt-service';
import { RefreshTokenStore, RefreshTokenData } from './refresh-token-store';
export interface LoginCredentials {
email: string;
password: string;
userAgent?: string;
ipAddress?: string;
deviceId?: string;
}
export interface RegisterData {
email: string;
password: string;
name: string;
tenantName?: string;
}
export interface User {
id: string;
email: string;
name: string;
passwordHash: string;
role: 'admin' | 'user' | 'viewer';
tenantId: string;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
}
export class AuthService {
private jwtService: JWTService;
private refreshTokenStore: RefreshTokenStore;
// 密碼 Hash 的 cost factor (越高越安全但越慢)
private readonly saltRounds = 12;
constructor(jwtService: JWTService, refreshTokenStore: RefreshTokenStore) {
this.jwtService = jwtService;
this.refreshTokenStore = refreshTokenStore;
}
/**
* 密碼 Hash
*/
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.saltRounds);
}
/**
* 驗證密碼
*/
async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* 註冊
*/
async register(data: RegisterData, db: any): Promise<{
user: User;
tokens: TokenPair;
}> {
// 檢查 Email 是否已存在
const existingUser = await db.user.findUnique({
where: { email: data.email },
});
if (existingUser) {
throw new Error('Email already exists');
}
// Hash 密碼
const passwordHash = await this.hashPassword(data.password);
// 建立租戶(如果提供)
let tenantId: string;
if (data.tenantName) {
const tenant = await db.tenant.create({
data: {
name: data.tenantName,
slug: this.slugify(data.tenantName),
},
});
tenantId = tenant.id;
} else {
// 使用預設租戶或建立個人租戶
const tenant = await db.tenant.create({
data: {
name: `${data.name}'s Workspace`,
slug: this.slugify(data.email),
},
});
tenantId = tenant.id;
}
// 建立用戶
const user = await db.user.create({
data: {
email: data.email,
name: data.name,
passwordHash,
role: 'admin', // 首位用戶為管理員
tenantId,
emailVerified: false,
},
});
// 生成 Token
const tokens = await this.generateTokensForUser(user);
// 發送驗證郵件(非同步)
this.sendVerificationEmail(user).catch((err) => {
console.error('Failed to send verification email:', err);
});
return { user, tokens };
}
/**
* 登入
*/
async login(
credentials: LoginCredentials,
db: any
): Promise<{
user: User;
tokens: TokenPair;
}> {
// 查找用戶
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user) {
throw new Error('Invalid credentials');
}
// 驗證密碼
const isValid = await this.verifyPassword(
credentials.password,
user.passwordHash
);
if (!isValid) {
throw new Error('Invalid credentials');
}
// 生成 Token
const tokens = await this.generateTokensForUser(user, {
userAgent: credentials.userAgent,
ipAddress: credentials.ipAddress,
deviceId: credentials.deviceId,
});
// 記錄登入
await db.loginLog.create({
data: {
userId: user.id,
ipAddress: credentials.ipAddress,
userAgent: credentials.userAgent,
success: true,
},
});
return { user, tokens };
}
/**
* 刷新 Token
*/
async refreshTokens(
userId: string,
refreshToken: string,
db: any
): Promise<TokenPair> {
// 驗證 Refresh Token
const tokenData = await this.refreshTokenStore.verifyRefreshToken(
userId,
refreshToken
);
if (!tokenData) {
throw new Error('Invalid refresh token');
}
// 取得用戶資訊
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('User not found');
}
// 生成新的 Token 對
const newTokens = await this.generateTokensForUser(user, {
userAgent: tokenData.userAgent,
ipAddress: tokenData.ipAddress,
deviceId: tokenData.deviceId,
});
// Token Rotation: 撤銷舊的 Refresh Token
await this.refreshTokenStore.revokeRefreshToken(userId, refreshToken);
return newTokens;
}
/**
* 登出
*/
async logout(userId: string, refreshToken: string): Promise<void> {
await this.refreshTokenStore.revokeRefreshToken(userId, refreshToken);
}
/**
* 登出所有裝置
*/
async logoutAllDevices(userId: string): Promise<void> {
await this.refreshTokenStore.revokeAllUserTokens(userId);
}
/**
* 驗證 Access Token
*/
async verifyAccessToken(token: string) {
return this.jwtService.verifyAccessToken(token);
}
/**
* 生成用戶的 Token 對
*/
private async generateTokensForUser(
user: User,
metadata?: {
userAgent?: string;
ipAddress?: string;
deviceId?: string;
}
): Promise<TokenPair> {
const tokens = await this.jwtService.generateTokenPair({
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
});
// 儲存 Refresh Token
const now = new Date();
await this.refreshTokenStore.storeRefreshToken({
userId: user.id,
token: tokens.refreshToken,
createdAt: now,
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 7 天
userAgent: metadata?.userAgent,
ipAddress: metadata?.ipAddress,
deviceId: metadata?.deviceId,
});
return tokens;
}
/**
* 發送驗證郵件(示意)
*/
private async sendVerificationEmail(user: User): Promise<void> {
// TODO: 整合 Email 服務
console.log(`Sending verification email to ${user.email}`);
}
/**
* 字串轉 slug
*/
private slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}
// apps/kyo-otp-service/src/plugins/auth.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import { JWTService, JWTTokenPayload } from '@kyong/kyo-core/auth';
declare module 'fastify' {
interface FastifyRequest {
user?: JWTTokenPayload;
requireAuth(): Promise<JWTTokenPayload>;
requireRole(role: 'admin' | 'user' | 'viewer'): Promise<JWTTokenPayload>;
}
}
const authPlugin: FastifyPluginAsync = async (server) => {
const jwtService = new JWTService({
accessTokenSecret: process.env.JWT_ACCESS_SECRET!,
refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
});
// 裝飾 Request 物件
server.decorateRequest('user', null);
/**
* 要求認證
*/
server.decorateRequest('requireAuth', function (this: any) {
return async (): Promise<JWTTokenPayload> => {
const authHeader = this.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw server.httpErrors.unauthorized('Missing or invalid authorization header');
}
const token = authHeader.substring(7);
try {
const payload = await jwtService.verifyAccessToken(token);
this.user = payload;
return payload;
} catch (error: any) {
if (error.message === 'Token expired') {
throw server.httpErrors.unauthorized('Token expired');
}
throw server.httpErrors.unauthorized('Invalid token');
}
};
});
/**
* 要求特定角色
*/
server.decorateRequest('requireRole', function (this: any) {
return async (requiredRole: 'admin' | 'user' | 'viewer'): Promise<JWTTokenPayload> => {
const user = await this.requireAuth();
const roleHierarchy = { viewer: 0, user: 1, admin: 2 };
const userRoleLevel = roleHierarchy[user.role];
const requiredRoleLevel = roleHierarchy[requiredRole];
if (userRoleLevel < requiredRoleLevel) {
throw server.httpErrors.forbidden(
`Requires ${requiredRole} role, but user has ${user.role} role`
);
}
return user;
};
});
/**
* onRequest Hook: 自動解析 Token(不強制)
*/
server.addHook('onRequest', async (request) => {
const authHeader = request.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const payload = await jwtService.verifyAccessToken(token);
request.user = payload;
} catch {
// 忽略錯誤,讓路由自行決定是否需要認證
}
}
});
};
export default fp(authPlugin, {
name: 'auth',
});
// apps/kyo-otp-service/src/routes/auth.ts
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { AuthService } from '@kyong/kyo-core/auth';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const registerSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(8)
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain number')
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
name: z.string().min(2),
tenantName: z.string().optional(),
});
export const authRoutes: FastifyPluginAsync = async (server) => {
const authService = server.authService as AuthService;
const db = server.db; // Prisma client
/**
* 註冊
*/
server.post('/register', async (request, reply) => {
const body = registerSchema.parse(request.body);
const { user, tokens } = await authService.register(body, db);
// 設定 Refresh Token Cookie (httpOnly)
reply.setCookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60, // 7 天
path: '/api/auth/refresh',
});
return reply.code(201).send({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
tenantId: user.tenantId,
},
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
/**
* 登入
*/
server.post('/login', async (request, reply) => {
const body = loginSchema.parse(request.body);
const { user, tokens } = await authService.login(
{
email: body.email,
password: body.password,
userAgent: request.headers['user-agent'],
ipAddress: request.ip,
},
db
);
// 設定 Refresh Token Cookie
reply.setCookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60,
path: '/api/auth/refresh',
});
return reply.send({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
tenantId: user.tenantId,
emailVerified: user.emailVerified,
},
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
/**
* 刷新 Token
*/
server.post('/refresh', async (request, reply) => {
const refreshToken = request.cookies.refreshToken;
if (!refreshToken) {
throw server.httpErrors.unauthorized('No refresh token provided');
}
// 從 Token 解析 userId(實際應該從 Redis 查詢)
// 這裡簡化處理,實際應該有 userId 參數或從 Token 查詢
const userId = request.body?.userId;
if (!userId) {
throw server.httpErrors.badRequest('User ID required');
}
const tokens = await authService.refreshTokens(userId, refreshToken, db);
// 更新 Refresh Token Cookie
reply.setCookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60,
path: '/api/auth/refresh',
});
return reply.send({
success: true,
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
/**
* 登出
*/
server.post('/logout', async (request, reply) => {
const user = await request.requireAuth();
const refreshToken = request.cookies.refreshToken;
if (refreshToken) {
await authService.logout(user.userId, refreshToken);
}
// 清除 Cookie
reply.clearCookie('refreshToken', {
path: '/api/auth/refresh',
});
return reply.send({
success: true,
message: 'Logged out successfully',
});
});
/**
* 登出所有裝置
*/
server.post('/logout-all', async (request, reply) => {
const user = await request.requireAuth();
await authService.logoutAllDevices(user.userId);
// 清除當前 Cookie
reply.clearCookie('refreshToken', {
path: '/api/auth/refresh',
});
return reply.send({
success: true,
message: 'Logged out from all devices',
});
});
/**
* 取得當前用戶資訊
*/
server.get('/me', async (request, reply) => {
const tokenPayload = await request.requireAuth();
const user = await db.user.findUnique({
where: { id: tokenPayload.userId },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
emailVerified: true,
createdAt: true,
},
});
if (!user) {
throw server.httpErrors.notFound('User not found');
}
return reply.send({
success: true,
user,
});
});
/**
* 更新個人資料
*/
server.patch('/profile', async (request, reply) => {
const tokenPayload = await request.requireAuth();
const updateSchema = z.object({
name: z.string().min(2).optional(),
avatar: z.string().url().optional(),
});
const body = updateSchema.parse(request.body);
const user = await db.user.update({
where: { id: tokenPayload.userId },
data: body,
select: {
id: true,
email: true,
name: true,
avatar: true,
role: true,
tenantId: true,
},
});
return reply.send({
success: true,
user,
});
});
/**
* 取得活躍裝置列表
*/
server.get('/devices', async (request, reply) => {
const user = await request.requireAuth();
const tokens = await server.refreshTokenStore.getUserTokens(user.userId);
const devices = tokens.map((token) => ({
deviceId: token.deviceId || 'unknown',
userAgent: token.userAgent || 'unknown',
ipAddress: token.ipAddress || 'unknown',
createdAt: token.createdAt,
expiresAt: token.expiresAt,
}));
return reply.send({
success: true,
devices,
});
});
};
/**
* 安全性最佳實踐
*
* 1. Token 安全
* ✅ Access Token: 短期(15分鐘)
* ✅ Refresh Token: 長期(7天)但可撤銷
* ✅ Token Rotation: 每次刷新都發新的 Refresh Token
* ✅ httpOnly Cookie: 防止 XSS 竊取
* ✅ Secure Flag: HTTPS only
* ✅ SameSite: 防止 CSRF
*
* 2. 密碼安全
* ✅ bcrypt Hash (cost factor 12)
* ✅ 密碼強度驗證
* ✅ 登入失敗次數限制
* ✅ 密碼重設流程
*
* 3. 速率限制
* ✅ 登入: 5 次/分鐘
* ✅ 註冊: 3 次/小時
* ✅ 密碼重設: 3 次/小時
* ✅ Token 刷新: 10 次/分鐘
*
* 4. 審計日誌
* ✅ 記錄所有登入嘗試
* ✅ 記錄 Token 刷新
* ✅ 記錄權限變更
* ✅ 記錄敏感操作
*
* 5. 多因素認證(未來實作)
* ⏳ TOTP (Time-based OTP)
* ⏳ SMS OTP
* ⏳ Email OTP
* ⏳ WebAuthn/FIDO2
*/
// 速率限制範例
import rateLimit from '@fastify/rate-limit';
export const authRateLimiter = rateLimit({
max: 5,
timeWindow: '1 minute',
keyGenerator: (request) => {
return request.ip;
},
errorResponseBuilder: () => {
return {
error: 'Too Many Requests',
message: 'Rate limit exceeded, please try again later',
statusCode: 429,
};
},
});
// 登入失敗追蹤
export class LoginAttemptTracker {
private redis: Redis;
private readonly maxAttempts = 5;
private readonly lockoutDuration = 15 * 60; // 15 分鐘
constructor(redis: Redis) {
this.redis = redis;
}
async recordFailedAttempt(identifier: string): Promise<void> {
const key = `login_attempts:${identifier}`;
const attempts = await this.redis.incr(key);
if (attempts === 1) {
await this.redis.expire(key, this.lockoutDuration);
}
if (attempts >= this.maxAttempts) {
const lockKey = `login_locked:${identifier}`;
await this.redis.setex(lockKey, this.lockoutDuration, '1');
}
}
async isLocked(identifier: string): Promise<boolean> {
const lockKey = `login_locked:${identifier}`;
const locked = await this.redis.get(lockKey);
return locked === '1';
}
async resetAttempts(identifier: string): Promise<void> {
const key = `login_attempts:${identifier}`;
const lockKey = `login_locked:${identifier}`;
await Promise.all([
this.redis.del(key),
this.redis.del(lockKey),
]);
}
async getRemainingAttempts(identifier: string): Promise<number> {
const key = `login_attempts:${identifier}`;
const attempts = await this.redis.get(key);
return this.maxAttempts - (parseInt(attempts || '0', 10));
}
}
我們今天完成了完整的後端認證系統:
為什麼混合式 Token?:
bcrypt vs Argon2:
Token 過期時間選擇:
Token Rotation 重要性: