在 Day 18 我們完成了後端效能優化,今天我們要建立身份驗證與授權系統。對於健身房 SaaS 系統,安全性是核心要求 - 從多租戶資料隔離到細粒度權限控制,我們將實作企業級的後端安全架構,包括 JWT + Refresh Token 機制、RBAC 權限體系、API 安全防護等安全模組。
// src/services/JWTService.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { FastifyInstance } from 'fastify';
interface JWTPayload {
sub: string; // UserID
email: string; // User信箱
gymId: string; // 所屬健身房ID (多租戶)
roles: string[]; // User角色
permissions: string[]; // User權限
sessionId: string; // 會話ID
iat: number; // 簽發時間
exp: number; // 過期時間
}
interface RefreshTokenData {
userId: string;
sessionId: string;
gymId: string;
createdAt: Date;
expiresAt: Date;
isRevoked: boolean;
deviceFingerprint?: string;
ipAddress?: string;
}
export class JWTService {
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiry: string;
private readonly refreshTokenExpiry: string;
private readonly issuer: string;
constructor() {
this.accessTokenSecret = process.env.JWT_ACCESS_SECRET || this.generateSecret();
this.refreshTokenSecret = process.env.JWT_REFRESH_SECRET || this.generateSecret();
this.accessTokenExpiry = process.env.JWT_ACCESS_EXPIRY || '15m';
this.refreshTokenExpiry = process.env.JWT_REFRESH_EXPIRY || '7d';
this.issuer = process.env.JWT_ISSUER || 'kyo-saas.com';
}
private generateSecret(): string {
return crypto.randomBytes(64).toString('hex');
}
// 產生 Access Token
generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
const now = Math.floor(Date.now() / 1000);
const tokenPayload: JWTPayload = {
...payload,
iat: now,
exp: now + this.parseExpiry(this.accessTokenExpiry)
};
return jwt.sign(tokenPayload, this.accessTokenSecret, {
issuer: this.issuer,
algorithm: 'HS256'
});
}
// 產生 Refresh Token
generateRefreshToken(userId: string, sessionId: string, gymId: string, deviceInfo?: any): string {
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: userId,
sessionId,
gymId,
type: 'refresh',
deviceFingerprint: deviceInfo?.fingerprint,
iat: now,
exp: now + this.parseExpiry(this.refreshTokenExpiry)
};
return jwt.sign(payload, this.refreshTokenSecret, {
issuer: this.issuer,
algorithm: 'HS256'
});
}
// 驗證 Access Token
verifyAccessToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, this.accessTokenSecret, {
issuer: this.issuer,
algorithms: ['HS256']
}) as JWTPayload;
return decoded;
} catch (error) {
console.error('Access token verification failed:', error);
return null;
}
}
// 驗證 Refresh Token
verifyRefreshToken(token: string): any | null {
try {
const decoded = jwt.verify(token, this.refreshTokenSecret, {
issuer: this.issuer,
algorithms: ['HS256']
});
return decoded;
} catch (error) {
console.error('Refresh token verification failed:', error);
return null;
}
}
// 取得 Token 過期時間
getTokenExpiration(token: string): Date | null {
try {
const decoded = jwt.decode(token) as any;
return decoded?.exp ? new Date(decoded.exp * 1000) : null;
} catch (error) {
return null;
}
}
// 檢查 Token 是否即將過期
isTokenExpiringSoon(token: string, thresholdMinutes: number = 5): boolean {
const expiration = this.getTokenExpiration(token);
if (!expiration) return true;
const now = new Date();
const threshold = new Date(now.getTime() + thresholdMinutes * 60 * 1000);
return expiration <= threshold;
}
private parseExpiry(expiry: string): number {
const unit = expiry.slice(-1);
const value = parseInt(expiry.slice(0, -1));
switch (unit) {
case 's': return value;
case 'm': return value * 60;
case 'h': return value * 60 * 60;
case 'd': return value * 24 * 60 * 60;
default: throw new Error(`Invalid expiry format: ${expiry}`);
}
}
// 產生會話ID
generateSessionId(): string {
return crypto.randomUUID();
}
// 產生設備指紋
generateDeviceFingerprint(userAgent: string, ip: string): string {
const hash = crypto.createHash('sha256');
hash.update(`${userAgent}:${ip}:${process.env.DEVICE_SALT || 'default-salt'}`);
return hash.digest('hex');
}
}
// src/services/RefreshTokenService.ts
import { OptimizedDatabase } from './OptimizedDatabase';
import { JWTService } from './JWTService';
export class RefreshTokenService {
private db: OptimizedDatabase;
private jwtService: JWTService;
constructor(database: OptimizedDatabase, jwtService: JWTService) {
this.db = database;
this.jwtService = jwtService;
}
// 儲存 Refresh Token
async storeRefreshToken(tokenData: RefreshTokenData): Promise<void> {
const query = `
INSERT INTO refresh_tokens (
user_id, session_id, gym_id, token_hash,
created_at, expires_at, device_fingerprint, ip_address
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`;
const tokenHash = this.hashToken(tokenData.userId + tokenData.sessionId);
await this.db.executeQuery(query, [
tokenData.userId,
tokenData.sessionId,
tokenData.gymId,
tokenHash,
tokenData.createdAt,
tokenData.expiresAt,
tokenData.deviceFingerprint,
tokenData.ipAddress
]);
}
// 驗證 Refresh Token
async validateRefreshToken(token: string, deviceFingerprint?: string): Promise<RefreshTokenData | null> {
const decoded = this.jwtService.verifyRefreshToken(token);
if (!decoded) return null;
const query = `
SELECT rt.*, u.email, u.roles, u.permissions
FROM refresh_tokens rt
JOIN users u ON rt.user_id = u.id
WHERE rt.user_id = $1
AND rt.session_id = $2
AND rt.gym_id = $3
AND rt.expires_at > NOW()
AND rt.is_revoked = false
`;
const results = await this.db.executeQuery<any>(query, [
decoded.sub,
decoded.sessionId,
decoded.gymId
]);
if (results.length === 0) return null;
const tokenData = results[0];
// 驗證設備指紋 (如果提供)
if (deviceFingerprint && tokenData.device_fingerprint !== deviceFingerprint) {
console.warn('Device fingerprint mismatch for refresh token');
await this.revokeToken(decoded.sub, decoded.sessionId);
return null;
}
return {
userId: tokenData.user_id,
sessionId: tokenData.session_id,
gymId: tokenData.gym_id,
createdAt: tokenData.created_at,
expiresAt: tokenData.expires_at,
isRevoked: tokenData.is_revoked,
deviceFingerprint: tokenData.device_fingerprint,
ipAddress: tokenData.ip_address
};
}
// 刷新 Token 對
async refreshTokens(
refreshToken: string,
deviceFingerprint?: string,
ipAddress?: string
): Promise<{ accessToken: string; newRefreshToken: string } | null> {
const tokenData = await this.validateRefreshToken(refreshToken, deviceFingerprint);
if (!tokenData) return null;
// 取得User完整資訊
const userQuery = `
SELECT u.*, array_agg(DISTINCT ur.role_name) as roles,
array_agg(DISTINCT p.permission_name) as permissions
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN role_permissions rp ON ur.role_name = rp.role_name
LEFT JOIN permissions p ON rp.permission_name = p.permission_name
WHERE u.id = $1 AND u.gym_id = $2 AND u.is_active = true
GROUP BY u.id
`;
const userResults = await this.db.executeQuery<any>(userQuery, [
tokenData.userId,
tokenData.gymId
]);
if (userResults.length === 0) return null;
const user = userResults[0];
// 撤銷舊的 Refresh Token
await this.revokeToken(tokenData.userId, tokenData.sessionId);
// 產生新的會話ID
const newSessionId = this.jwtService.generateSessionId();
// 產生新的 Access Token
const accessToken = this.jwtService.generateAccessToken({
sub: user.id,
email: user.email,
gymId: user.gym_id,
roles: user.roles || [],
permissions: user.permissions || [],
sessionId: newSessionId
});
// 產生新的 Refresh Token
const newRefreshToken = this.jwtService.generateRefreshToken(
user.id,
newSessionId,
user.gym_id,
{ fingerprint: deviceFingerprint }
);
// 儲存新的 Refresh Token
await this.storeRefreshToken({
userId: user.id,
sessionId: newSessionId,
gymId: user.gym_id,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 天
isRevoked: false,
deviceFingerprint,
ipAddress
});
return { accessToken, newRefreshToken };
}
// 撤銷 Refresh Token
async revokeToken(userId: string, sessionId: string): Promise<void> {
const query = `
UPDATE refresh_tokens
SET is_revoked = true, revoked_at = NOW()
WHERE user_id = $1 AND session_id = $2
`;
await this.db.executeQuery(query, [userId, sessionId]);
}
// 撤銷User所有 Token
async revokeAllUserTokens(userId: string, gymId: string): Promise<void> {
const query = `
UPDATE refresh_tokens
SET is_revoked = true, revoked_at = NOW()
WHERE user_id = $1 AND gym_id = $2 AND is_revoked = false
`;
await this.db.executeQuery(query, [userId, gymId]);
}
// 清理過期 Token
async cleanExpiredTokens(): Promise<number> {
const query = `
DELETE FROM refresh_tokens
WHERE expires_at < NOW() OR (is_revoked = true AND revoked_at < NOW() - INTERVAL '30 days')
`;
const result = await this.db.executeQuery(query);
return result.length;
}
// 取得User活躍會話
async getUserActiveSessions(userId: string, gymId: string): Promise<any[]> {
const query = `
SELECT session_id, created_at, ip_address, device_fingerprint,
CASE
WHEN expires_at > NOW() THEN 'active'
ELSE 'expired'
END as status
FROM refresh_tokens
WHERE user_id = $1 AND gym_id = $2 AND is_revoked = false
ORDER BY created_at DESC
`;
return this.db.executeQuery(query, [userId, gymId]);
}
private hashToken(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex');
}
}
// src/services/PermissionService.ts
export enum Role {
SUPER_ADMIN = 'super_admin',
GYM_OWNER = 'gym_owner',
GYM_MANAGER = 'gym_manager',
TRAINER = 'trainer',
MEMBER = 'member'
}
export enum Permission {
// 健身房管理
MANAGE_GYM = 'manage_gym',
VIEW_GYM_ANALYTICS = 'view_gym_analytics',
// 會員管理
MANAGE_MEMBERS = 'manage_members',
VIEW_MEMBERS = 'view_members',
MEMBER_CHECK_IN = 'member_check_in',
// 課程管理
MANAGE_COURSES = 'manage_courses',
VIEW_COURSES = 'view_courses',
BOOK_COURSES = 'book_courses',
// 財務管理
MANAGE_BILLING = 'manage_billing',
VIEW_BILLING = 'view_billing',
PROCESS_PAYMENTS = 'process_payments',
// User管理
MANAGE_USERS = 'manage_users',
VIEW_USERS = 'view_users',
// 系統管理
SYSTEM_CONFIG = 'system_config',
VIEW_LOGS = 'view_logs'
}
interface RolePermissionMatrix {
[Role.SUPER_ADMIN]: Permission[];
[Role.GYM_OWNER]: Permission[];
[Role.GYM_MANAGER]: Permission[];
[Role.TRAINER]: Permission[];
[Role.MEMBER]: Permission[];
}
export class PermissionService {
private db: OptimizedDatabase;
// 預設角色權限矩陣
private readonly defaultRolePermissions: RolePermissionMatrix = {
[Role.SUPER_ADMIN]: Object.values(Permission),
[Role.GYM_OWNER]: [
Permission.MANAGE_GYM,
Permission.VIEW_GYM_ANALYTICS,
Permission.MANAGE_MEMBERS,
Permission.VIEW_MEMBERS,
Permission.MEMBER_CHECK_IN,
Permission.MANAGE_COURSES,
Permission.VIEW_COURSES,
Permission.MANAGE_BILLING,
Permission.VIEW_BILLING,
Permission.PROCESS_PAYMENTS,
Permission.MANAGE_USERS,
Permission.VIEW_USERS,
Permission.VIEW_LOGS
],
[Role.GYM_MANAGER]: [
Permission.VIEW_GYM_ANALYTICS,
Permission.MANAGE_MEMBERS,
Permission.VIEW_MEMBERS,
Permission.MEMBER_CHECK_IN,
Permission.MANAGE_COURSES,
Permission.VIEW_COURSES,
Permission.VIEW_BILLING,
Permission.VIEW_USERS
],
[Role.TRAINER]: [
Permission.VIEW_MEMBERS,
Permission.MEMBER_CHECK_IN,
Permission.MANAGE_COURSES,
Permission.VIEW_COURSES
],
[Role.MEMBER]: [
Permission.VIEW_COURSES,
Permission.BOOK_COURSES
]
};
constructor(database: OptimizedDatabase) {
this.db = database;
}
// 檢查User權限
async hasPermission(userId: string, gymId: string, permission: Permission): Promise<boolean> {
try {
// 首先檢查直接權限
const directPermissionQuery = `
SELECT 1 FROM user_permissions up
WHERE up.user_id = $1 AND up.gym_id = $2 AND up.permission_name = $3
`;
const directResult = await this.db.executeQuery(directPermissionQuery, [
userId, gymId, permission
]);
if (directResult.length > 0) return true;
// 檢查角色權限
const rolePermissionQuery = `
SELECT 1 FROM user_roles ur
JOIN role_permissions rp ON ur.role_name = rp.role_name
WHERE ur.user_id = $1 AND ur.gym_id = $2 AND rp.permission_name = $3
`;
const roleResult = await this.db.executeQuery(rolePermissionQuery, [
userId, gymId, permission
]);
return roleResult.length > 0;
} catch (error) {
console.error('Permission check failed:', error);
return false;
}
}
// 檢查User角色
async hasRole(userId: string, gymId: string, role: Role): Promise<boolean> {
try {
const query = `
SELECT 1 FROM user_roles
WHERE user_id = $1 AND gym_id = $2 AND role_name = $3
`;
const result = await this.db.executeQuery(query, [userId, gymId, role]);
return result.length > 0;
} catch (error) {
console.error('Role check failed:', error);
return false;
}
}
// 取得User所有權限
async getUserPermissions(userId: string, gymId: string): Promise<Permission[]> {
try {
const query = `
SELECT DISTINCT p.permission_name
FROM (
-- 直接權限
SELECT permission_name FROM user_permissions
WHERE user_id = $1 AND gym_id = $2
UNION
-- 角色權限
SELECT rp.permission_name
FROM user_roles ur
JOIN role_permissions rp ON ur.role_name = rp.role_name
WHERE ur.user_id = $1 AND ur.gym_id = $2
) p
`;
const result = await this.db.executeQuery<{ permission_name: Permission }>(query, [
userId, gymId
]);
return result.map(row => row.permission_name);
} catch (error) {
console.error('Failed to get user permissions:', error);
return [];
}
}
// 取得User角色
async getUserRoles(userId: string, gymId: string): Promise<Role[]> {
try {
const query = `
SELECT role_name FROM user_roles
WHERE user_id = $1 AND gym_id = $2
`;
const result = await this.db.executeQuery<{ role_name: Role }>(query, [
userId, gymId
]);
return result.map(row => row.role_name);
} catch (error) {
console.error('Failed to get user roles:', error);
return [];
}
}
// 指派角色給User
async assignRole(userId: string, gymId: string, role: Role, assignedBy: string): Promise<void> {
try {
const query = `
INSERT INTO user_roles (user_id, gym_id, role_name, assigned_by, assigned_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id, gym_id, role_name) DO NOTHING
`;
await this.db.executeQuery(query, [userId, gymId, role, assignedBy]);
// 記錄權限變更日誌
await this.logPermissionChange(userId, gymId, 'ROLE_ASSIGNED', role, assignedBy);
} catch (error) {
console.error('Failed to assign role:', error);
throw error;
}
}
// 移除User角色
async removeRole(userId: string, gymId: string, role: Role, removedBy: string): Promise<void> {
try {
const query = `
DELETE FROM user_roles
WHERE user_id = $1 AND gym_id = $2 AND role_name = $3
`;
await this.db.executeQuery(query, [userId, gymId, role]);
// 記錄權限變更日誌
await this.logPermissionChange(userId, gymId, 'ROLE_REMOVED', role, removedBy);
} catch (error) {
console.error('Failed to remove role:', error);
throw error;
}
}
// 直接授予權限
async grantPermission(userId: string, gymId: string, permission: Permission, grantedBy: string): Promise<void> {
try {
const query = `
INSERT INTO user_permissions (user_id, gym_id, permission_name, granted_by, granted_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id, gym_id, permission_name) DO NOTHING
`;
await this.db.executeQuery(query, [userId, gymId, permission, grantedBy]);
// 記錄權限變更日誌
await this.logPermissionChange(userId, gymId, 'PERMISSION_GRANTED', permission, grantedBy);
} catch (error) {
console.error('Failed to grant permission:', error);
throw error;
}
}
// 撤銷權限
async revokePermission(userId: string, gymId: string, permission: Permission, revokedBy: string): Promise<void> {
try {
const query = `
DELETE FROM user_permissions
WHERE user_id = $1 AND gym_id = $2 AND permission_name = $3
`;
await this.db.executeQuery(query, [userId, gymId, permission]);
// 記錄權限變更日誌
await this.logPermissionChange(userId, gymId, 'PERMISSION_REVOKED', permission, revokedBy);
} catch (error) {
console.error('Failed to revoke permission:', error);
throw error;
}
}
// 權限變更日誌
private async logPermissionChange(
userId: string,
gymId: string,
action: string,
target: string,
changedBy: string
): Promise<void> {
try {
const query = `
INSERT INTO permission_audit_log (
user_id, gym_id, action, target, changed_by, changed_at
) VALUES ($1, $2, $3, $4, $5, NOW())
`;
await this.db.executeQuery(query, [userId, gymId, action, target, changedBy]);
} catch (error) {
console.error('Failed to log permission change:', error);
}
}
// 初始化預設角色權限
async initializeDefaultRolePermissions(): Promise<void> {
try {
for (const [role, permissions] of Object.entries(this.defaultRolePermissions)) {
for (const permission of permissions) {
const query = `
INSERT INTO role_permissions (role_name, permission_name)
VALUES ($1, $2)
ON CONFLICT (role_name, permission_name) DO NOTHING
`;
await this.db.executeQuery(query, [role, permission]);
}
}
console.log('Default role permissions initialized');
} catch (error) {
console.error('Failed to initialize default role permissions:', error);
throw error;
}
}
}
// src/middleware/AuthMiddleware.ts
import { FastifyRequest, FastifyReply } from 'fastify';
import { JWTService } from '../services/JWTService';
import { PermissionService, Permission, Role } from '../services/PermissionService';
interface AuthenticatedRequest extends FastifyRequest {
user?: {
id: string;
email: string;
gymId: string;
roles: Role[];
permissions: Permission[];
sessionId: string;
};
}
export class AuthMiddleware {
constructor(
private jwtService: JWTService,
private permissionService: PermissionService
) {}
// JWT 驗證中介軟體
authenticate = async (request: AuthenticatedRequest, reply: FastifyReply) => {
try {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Missing or invalid authorization header'
});
}
const token = authHeader.substring(7);
const payload = this.jwtService.verifyAccessToken(token);
if (!payload) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
});
}
// 檢查 Token 是否即將過期
if (this.jwtService.isTokenExpiringSoon(token)) {
reply.header('X-Token-Refresh-Required', 'true');
}
// 設置User資訊到請求物件
request.user = {
id: payload.sub,
email: payload.email,
gymId: payload.gymId,
roles: payload.roles,
permissions: payload.permissions,
sessionId: payload.sessionId
};
} catch (error) {
console.error('Authentication error:', error);
return reply.status(401).send({
error: 'Unauthorized',
message: 'Authentication failed'
});
}
};
// 權限檢查中介軟體工廠
requirePermission = (permission: Permission) => {
return async (request: AuthenticatedRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
});
}
const hasPermission = await this.permissionService.hasPermission(
request.user.id,
request.user.gymId,
permission
);
if (!hasPermission) {
return reply.status(403).send({
error: 'Forbidden',
message: `Insufficient permissions. Required: ${permission}`
});
}
};
};
// 角色檢查中介軟體工廠
requireRole = (role: Role) => {
return async (request: AuthenticatedRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
});
}
const hasRole = await this.permissionService.hasRole(
request.user.id,
request.user.gymId,
role
);
if (!hasRole) {
return reply.status(403).send({
error: 'Forbidden',
message: `Insufficient role. Required: ${role}`
});
}
};
};
// 多權限檢查(需要所有權限)
requireAllPermissions = (permissions: Permission[]) => {
return async (request: AuthenticatedRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
});
}
for (const permission of permissions) {
const hasPermission = await this.permissionService.hasPermission(
request.user.id,
request.user.gymId,
permission
);
if (!hasPermission) {
return reply.status(403).send({
error: 'Forbidden',
message: `Insufficient permissions. Missing: ${permission}`
});
}
}
};
};
// 多權限檢查(需要任一權限)
requireAnyPermission = (permissions: Permission[]) => {
return async (request: AuthenticatedRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
});
}
for (const permission of permissions) {
const hasPermission = await this.permissionService.hasPermission(
request.user.id,
request.user.gymId,
permission
);
if (hasPermission) {
return; // 有任一權限即可
}
}
return reply.status(403).send({
error: 'Forbidden',
message: `Insufficient permissions. Required one of: ${permissions.join(', ')}`
});
};
};
// 資源擁有者檢查
requireResourceOwnership = (resourceUserIdParam: string = 'userId') => {
return async (request: AuthenticatedRequest, reply: FastifyReply) => {
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
});
}
const resourceUserId = (request.params as any)[resourceUserIdParam];
// 超級管理員可以訪問所有資源
const isSuperAdmin = await this.permissionService.hasRole(
request.user.id,
request.user.gymId,
Role.SUPER_ADMIN
);
if (isSuperAdmin) {
return;
}
// 健身房擁有者可以訪問本健身房所有資源
const isGymOwner = await this.permissionService.hasRole(
request.user.id,
request.user.gymId,
Role.GYM_OWNER
);
if (isGymOwner) {
return;
}
// 其他情況需要是資源擁有者
if (request.user.id !== resourceUserId) {
return reply.status(403).send({
error: 'Forbidden',
message: 'Access denied. You can only access your own resources.'
});
}
};
};
}
// src/middleware/TenantIsolationMiddleware.ts
import { FastifyRequest, FastifyReply } from 'fastify';
import { OptimizedDatabase } from '../services/OptimizedDatabase';
interface TenantRequest extends FastifyRequest {
gymId?: string;
user?: {
id: string;
gymId: string;
roles: string[];
};
}
export class TenantIsolationMiddleware {
constructor(private db: OptimizedDatabase) {}
// 租戶隔離中介軟體
enforceTenantIsolation = async (request: TenantRequest, reply: FastifyReply) => {
try {
// 檢查User是否已認證
if (!request.user) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'User authentication required'
});
}
// 從路由參數取得 gymId
const routeGymId = (request.params as any)?.gymId;
// 從查詢參數取得 gymId
const queryGymId = (request.query as any)?.gymId;
// 從請求體取得 gymId
const bodyGymId = (request.body as any)?.gymId;
// 確定目標健身房ID
const targetGymId = routeGymId || queryGymId || bodyGymId || request.user.gymId;
// 超級管理員可以訪問所有租戶
if (request.user.roles.includes('super_admin')) {
request.gymId = targetGymId;
return;
}
// 檢查User是否屬於目標健身房
if (request.user.gymId !== targetGymId) {
return reply.status(403).send({
error: 'Forbidden',
message: 'Access denied. Cross-tenant access not allowed.'
});
}
// 驗證健身房是否存在且活躍
const gymExists = await this.validateGym(targetGymId);
if (!gymExists) {
return reply.status(404).send({
error: 'Not Found',
message: 'Gym not found or inactive'
});
}
// 設置租戶上下文
request.gymId = targetGymId;
} catch (error) {
console.error('Tenant isolation error:', error);
return reply.status(500).send({
error: 'Internal Server Error',
message: 'Tenant validation failed'
});
}
};
// 驗證健身房
private async validateGym(gymId: string): Promise<boolean> {
try {
const query = `
SELECT 1 FROM gyms
WHERE id = $1 AND is_active = true AND deleted_at IS NULL
`;
const result = await this.db.executeQuery(query, [gymId]);
return result.length > 0;
} catch (error) {
console.error('Gym validation failed:', error);
return false;
}
}
// 資料庫查詢包裝器 - 自動添加租戶過濾
createTenantQuery = (baseQuery: string, gymIdParam: string = 'gym_id'): string => {
// 檢查查詢是否已包含 WHERE 子句
const hasWhere = /\bWHERE\b/i.test(baseQuery);
const tenantFilter = hasWhere
? ` AND ${gymIdParam} = $gymId`
: ` WHERE ${gymIdParam} = $gymId`;
return baseQuery + tenantFilter;
};
// 安全的租戶查詢執行器
executeTenantQuery = async <T>(
request: TenantRequest,
query: string,
params: any[] = [],
gymIdParam: string = 'gym_id'
): Promise<T[]> => {
if (!request.gymId) {
throw new Error('Tenant context not set');
}
// 添加租戶過濾
const tenantQuery = this.createTenantQuery(query, gymIdParam);
// 添加 gymId 參數
const tenantParams = [...params, request.gymId];
return this.db.executeQuery<T>(tenantQuery, tenantParams);
};
// 租戶資料插入包裝器
executeTenantInsert = async (
request: TenantRequest,
tableName: string,
data: Record<string, any>,
gymIdColumn: string = 'gym_id'
): Promise<any> => {
if (!request.gymId) {
throw new Error('Tenant context not set');
}
// 自動添加 gymId
const tenantData = {
...data,
[gymIdColumn]: request.gymId
};
const columns = Object.keys(tenantData);
const values = Object.values(tenantData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const query = `
INSERT INTO ${tableName} (${columns.join(', ')})
VALUES (${placeholders})
RETURNING *
`;
const result = await this.db.executeQuery(query, values);
return result[0];
};
// 租戶資料更新包裝器
executeTenantUpdate = async (
request: TenantRequest,
tableName: string,
id: string,
data: Record<string, any>,
gymIdColumn: string = 'gym_id'
): Promise<any> => {
if (!request.gymId) {
throw new Error('Tenant context not set');
}
const updateColumns = Object.keys(data);
const updateValues = Object.values(data);
const setClause = updateColumns
.map((col, index) => `${col} = $${index + 1}`)
.join(', ');
const query = `
UPDATE ${tableName}
SET ${setClause}, updated_at = NOW()
WHERE id = $${updateValues.length + 1}
AND ${gymIdColumn} = $${updateValues.length + 2}
RETURNING *
`;
const params = [...updateValues, id, request.gymId];
const result = await this.db.executeQuery(query, params);
if (result.length === 0) {
throw new Error('Record not found or access denied');
}
return result[0];
};
// 租戶資料刪除包裝器
executeTenantDelete = async (
request: TenantRequest,
tableName: string,
id: string,
gymIdColumn: string = 'gym_id',
softDelete: boolean = true
): Promise<boolean> => {
if (!request.gymId) {
throw new Error('Tenant context not set');
}
let query: string;
if (softDelete) {
query = `
UPDATE ${tableName}
SET deleted_at = NOW()
WHERE id = $1 AND ${gymIdColumn} = $2 AND deleted_at IS NULL
`;
} else {
query = `
DELETE FROM ${tableName}
WHERE id = $1 AND ${gymIdColumn} = $2
`;
}
const result = await this.db.executeQuery(query, [id, request.gymId]);
return result.length > 0;
};
}
// src/middleware/RateLimitMiddleware.ts
import { FastifyRequest, FastifyReply } from 'fastify';
import { OptimizedCache } from '../cache/OptimizedCache';
interface RateLimitConfig {
windowMs: number; // 時間窗口(毫秒)
maxRequests: number; // 最大請求數
skipSuccessfulRequests?: boolean;
skipFailedRequests?: boolean;
keyGenerator?: (request: FastifyRequest) => string;
onLimitReached?: (request: FastifyRequest, reply: FastifyReply) => void;
}
interface RateLimitInfo {
totalHits: number;
totalTime: number;
resetTime: Date;
}
export class RateLimitMiddleware {
private cache: OptimizedCache;
constructor(cache: OptimizedCache) {
this.cache = cache;
}
// 建立限流中介軟體
createRateLimit = (config: RateLimitConfig) => {
const {
windowMs,
maxRequests,
skipSuccessfulRequests = false,
skipFailedRequests = false,
keyGenerator = this.defaultKeyGenerator,
onLimitReached = this.defaultLimitHandler
} = config;
return async (request: FastifyRequest, reply: FastifyReply) => {
const key = keyGenerator(request);
const now = Date.now();
const windowStart = now - windowMs;
try {
// 取得目前的限流資訊
const current = await this.getCurrentLimit(key, windowStart, now);
// 檢查是否超過限制
if (current.totalHits >= maxRequests) {
// 設置限流回應標頭
this.setRateLimitHeaders(reply, current, maxRequests, windowMs);
// 執行限流回調
onLimitReached(request, reply);
return reply.status(429).send({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${Math.ceil((current.resetTime.getTime() - now) / 1000)} seconds.`,
retryAfter: Math.ceil((current.resetTime.getTime() - now) / 1000)
});
}
// 記錄本次請求
await this.recordRequest(key, now, windowMs);
// 設置限流資訊標頭
this.setRateLimitHeaders(reply, {
...current,
totalHits: current.totalHits + 1
}, maxRequests, windowMs);
// 在回應後檢查是否需要清理計數
reply.addHook('onSend', async () => {
if (skipSuccessfulRequests && reply.statusCode < 400) {
await this.removeRequest(key, now);
} else if (skipFailedRequests && reply.statusCode >= 400) {
await this.removeRequest(key, now);
}
});
} catch (error) {
console.error('Rate limit check failed:', error);
// 發生錯誤時允許請求通過,但記錄錯誤
}
};
};
// 進階限流:分層限制
createTieredRateLimit = (configs: Array<RateLimitConfig & { name: string }>) => {
return async (request: FastifyRequest, reply: FastifyReply) => {
for (const config of configs) {
const limitMiddleware = this.createRateLimit(config);
try {
await limitMiddleware(request, reply);
} catch (error) {
// 如果任一層限制觸發,停止檢查
return;
}
}
};
};
// 動態限流:根據User等級調整
createDynamicRateLimit = (baseConfig: RateLimitConfig) => {
return async (request: FastifyRequest, reply: FastifyReply) => {
const user = (request as any).user;
let multiplier = 1;
// 根據User角色調整限制
if (user?.roles) {
if (user.roles.includes('super_admin')) {
multiplier = 10;
} else if (user.roles.includes('gym_owner')) {
multiplier = 5;
} else if (user.roles.includes('gym_manager')) {
multiplier = 3;
} else if (user.roles.includes('trainer')) {
multiplier = 2;
}
}
const dynamicConfig = {
...baseConfig,
maxRequests: baseConfig.maxRequests * multiplier
};
const limitMiddleware = this.createRateLimit(dynamicConfig);
await limitMiddleware(request, reply);
};
};
// 取得目前限流狀態
private async getCurrentLimit(key: string, windowStart: number, now: number): Promise<RateLimitInfo> {
const requests = await this.cache.get<number[]>(`ratelimit:${key}`, 'api') || [];
// 過濾時間窗口內的請求
const validRequests = requests.filter(timestamp => timestamp > windowStart);
return {
totalHits: validRequests.length,
totalTime: now - windowStart,
resetTime: new Date(now + (60 * 1000)) // 預設重設時間為1分鐘後
};
}
// 記錄請求
private async recordRequest(key: string, timestamp: number, windowMs: number): Promise<void> {
const requests = await this.cache.get<number[]>(`ratelimit:${key}`, 'api') || [];
// 添加新請求時間戳
requests.push(timestamp);
// 過濾過期的請求
const windowStart = timestamp - windowMs;
const validRequests = requests.filter(ts => ts > windowStart);
// 更新快取
await this.cache.set(`ratelimit:${key}`, validRequests, 'api', Math.ceil(windowMs / 1000));
}
// 移除請求(用於跳過成功/失敗請求)
private async removeRequest(key: string, timestamp: number): Promise<void> {
const requests = await this.cache.get<number[]>(`ratelimit:${key}`, 'api') || [];
// 移除指定的時間戳
const updatedRequests = requests.filter(ts => ts !== timestamp);
await this.cache.set(`ratelimit:${key}`, updatedRequests, 'api');
}
// 設置限流回應標頭
private setRateLimitHeaders(
reply: FastifyReply,
current: RateLimitInfo,
maxRequests: number,
windowMs: number
): void {
reply.header('X-RateLimit-Limit', maxRequests);
reply.header('X-RateLimit-Remaining', Math.max(0, maxRequests - current.totalHits));
reply.header('X-RateLimit-Reset', Math.ceil(current.resetTime.getTime() / 1000));
reply.header('X-RateLimit-Window', Math.ceil(windowMs / 1000));
}
// 預設金鑰產生器
private defaultKeyGenerator = (request: FastifyRequest): string => {
const user = (request as any).user;
const ip = request.ip;
const route = request.routerPath || request.url;
if (user) {
return `user:${user.id}:${route}`;
}
return `ip:${ip}:${route}`;
};
// 預設限流處理器
private defaultLimitHandler = (request: FastifyRequest, reply: FastifyReply): void => {
console.warn(`Rate limit exceeded for ${request.ip} on ${request.url}`);
};
// 取得限流統計
async getRateLimitStats(key: string): Promise<RateLimitInfo | null> {
try {
const requests = await this.cache.get<number[]>(`ratelimit:${key}`, 'api');
if (!requests || requests.length === 0) {
return null;
}
const now = Date.now();
const windowStart = now - (60 * 1000); // 預設1分鐘窗口
const validRequests = requests.filter(timestamp => timestamp > windowStart);
return {
totalHits: validRequests.length,
totalTime: now - windowStart,
resetTime: new Date(now + (60 * 1000))
};
} catch (error) {
console.error('Failed to get rate limit stats:', error);
return null;
}
}
// 清除限流記錄
async clearRateLimit(key: string): Promise<void> {
await this.cache.invalidate(`ratelimit:${key}`);
}
}
// src/services/PasswordService.ts
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { promisify } from 'util';
const scrypt = promisify(crypto.scrypt);
interface PasswordPolicy {
minLength: number;
requireUppercase: boolean;
requireLowercase: boolean;
requireNumbers: boolean;
requireSpecialChars: boolean;
forbiddenPatterns: RegExp[];
maxAge: number; // 密碼最大使用天數
}
interface PasswordValidationResult {
isValid: boolean;
errors: string[];
strength: 'weak' | 'medium' | 'strong' | 'very_strong';
}
export class PasswordService {
private readonly saltRounds: number = 12;
private readonly pepperSecret: string;
private readonly defaultPolicy: PasswordPolicy = {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
forbiddenPatterns: [
/password/i,
/123456/,
/qwerty/i,
/admin/i,
/login/i
],
maxAge: 90 // 90 天
};
constructor() {
this.pepperSecret = process.env.PASSWORD_PEPPER || this.generatePepper();
}
private generatePepper(): string {
return crypto.randomBytes(32).toString('hex');
}
// 驗證密碼強度
validatePassword(password: string, policy: PasswordPolicy = this.defaultPolicy): PasswordValidationResult {
const errors: string[] = [];
// 長度檢查
if (password.length < policy.minLength) {
errors.push(`密碼長度至少需要 ${policy.minLength} 個字元`);
}
// 大寫字母檢查
if (policy.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('密碼必須包含至少一個大寫字母');
}
// 小寫字母檢查
if (policy.requireLowercase && !/[a-z]/.test(password)) {
errors.push('密碼必須包含至少一個小寫字母');
}
// 數字檢查
if (policy.requireNumbers && !/\d/.test(password)) {
errors.push('密碼必須包含至少一個數字');
}
// 特殊字元檢查
if (policy.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
errors.push('密碼必須包含至少一個特殊字元');
}
// 禁用模式檢查
for (const pattern of policy.forbiddenPatterns) {
if (pattern.test(password)) {
errors.push('密碼包含不安全的常見模式');
break;
}
}
// 計算密碼強度
const strength = this.calculatePasswordStrength(password);
return {
isValid: errors.length === 0,
errors,
strength
};
}
// 計算密碼強度
private calculatePasswordStrength(password: string): 'weak' | 'medium' | 'strong' | 'very_strong' {
let score = 0;
// 長度分數
if (password.length >= 8) score += 1;
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
// 字元多樣性分數
if (/[a-z]/.test(password)) score += 1;
if (/[A-Z]/.test(password)) score += 1;
if (/\d/.test(password)) score += 1;
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) score += 1;
// 複雜度分數
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) score += 1;
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/.test(password)) score += 1;
// 無重複字元模式
if (!/(.)\1{2,}/.test(password)) score += 1;
if (score >= 8) return 'very_strong';
if (score >= 6) return 'strong';
if (score >= 4) return 'medium';
return 'weak';
}
// 雜湊密碼 (bcrypt + pepper)
async hashPassword(password: string): Promise<string> {
try {
// 添加 pepper
const pepperedPassword = password + this.pepperSecret;
// 使用 bcrypt 雜湊
const hashedPassword = await bcrypt.hash(pepperedPassword, this.saltRounds);
return hashedPassword;
} catch (error) {
console.error('Password hashing failed:', error);
throw new Error('Password hashing failed');
}
}
// 驗證密碼
async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
// 添加 pepper
const pepperedPassword = password + this.pepperSecret;
// 使用 bcrypt 驗證
return await bcrypt.compare(pepperedPassword, hashedPassword);
} catch (error) {
console.error('Password verification failed:', error);
return false;
}
}
// 產生安全的隨機密碼
generateSecurePassword(length: number = 16): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let password = '';
// 確保包含所有字元類型
password += this.getRandomChar('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
password += this.getRandomChar('abcdefghijklmnopqrstuvwxyz');
password += this.getRandomChar('0123456789');
password += this.getRandomChar('!@#$%^&*');
// 填充剩餘長度
for (let i = 4; i < length; i++) {
password += this.getRandomChar(charset);
}
// 打亂密碼字元順序
return this.shuffleString(password);
}
private getRandomChar(charset: string): string {
const randomIndex = crypto.randomInt(0, charset.length);
return charset[randomIndex];
}
private shuffleString(str: string): string {
const array = str.split('');
for (let i = array.length - 1; i > 0; i--) {
const j = crypto.randomInt(0, i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array.join('');
}
// 密碼歷史檢查
async isPasswordReused(userId: string, newPassword: string, historyLimit: number = 5): Promise<boolean> {
// 這裡應該從資料庫取得User的密碼歷史
// 為了示範,我們假設有一個密碼歷史表
const db = new OptimizedDatabase();
const query = `
SELECT password_hash FROM password_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`;
const passwordHistory = await db.executeQuery<{ password_hash: string }>(query, [
userId, historyLimit
]);
// 檢查新密碼是否與歷史密碼相同
for (const historic of passwordHistory) {
if (await this.verifyPassword(newPassword, historic.password_hash)) {
return true;
}
}
return false;
}
// 儲存密碼歷史
async savePasswordHistory(userId: string, hashedPassword: string): Promise<void> {
const db = new OptimizedDatabase();
const insertQuery = `
INSERT INTO password_history (user_id, password_hash, created_at)
VALUES ($1, $2, NOW())
`;
await db.executeQuery(insertQuery, [userId, hashedPassword]);
// 清理舊的密碼歷史(只保留最近5個)
const cleanupQuery = `
DELETE FROM password_history
WHERE user_id = $1
AND id NOT IN (
SELECT id FROM password_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 5
)
`;
await db.executeQuery(cleanupQuery, [userId]);
}
// 檢查密碼是否過期
async isPasswordExpired(userId: string, maxAge: number = 90): Promise<boolean> {
const db = new OptimizedDatabase();
const query = `
SELECT password_updated_at
FROM users
WHERE id = $1
`;
const result = await db.executeQuery<{ password_updated_at: Date }>(query, [userId]);
if (result.length === 0) return true;
const passwordAge = Date.now() - result[0].password_updated_at.getTime();
const maxAgeMs = maxAge * 24 * 60 * 60 * 1000; // 轉換為毫秒
return passwordAge > maxAgeMs;
}
// 產生密碼重設 Token
generatePasswordResetToken(): { token: string; hashedToken: string; expiresAt: Date } {
const token = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1小時後過期
return { token, hashedToken, expiresAt };
}
// 驗證密碼重設 Token
verifyPasswordResetToken(token: string, hashedToken: string): boolean {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(tokenHash, 'hex'),
Buffer.from(hashedToken, 'hex')
);
}
}
今天我們建立了全面的後端身份驗證與授權系統: