iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 19

Day 19: 30天打造SaaS產品後端篇-身份驗證與授權系統

  • 分享至 

  • xImage
  •  

前情提要

在 Day 18 我們完成了後端效能優化,今天我們要建立身份驗證與授權系統。對於健身房 SaaS 系統,安全性是核心要求 - 從多租戶資料隔離到細粒度權限控制,我們將實作企業級的後端安全架構,包括 JWT + Refresh Token 機制、RBAC 權限體系、API 安全防護等安全模組。

JWT + Refresh Token 雙重驗證機制

JWT 服務核心實作

// 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');
  }
}

Refresh Token 管理系統

// 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');
  }
}

RBAC 角色權限控制系統

權限管理核心

// 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;
  };
}

API 安全與限流

進階限流系統

// 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')
    );
  }
}

今日總結

今天我們建立了全面的後端身份驗證與授權系統:

  1. JWT 雙重驗證:Access Token + Refresh Token 機制,支援設備指紋與會話管理
  2. RBAC 權限控制:細粒度角色權限體系,支援動態權限指派與審計
  3. 多租戶隔離:完整的資料隔離中介軟體,確保跨租戶資料安全
  4. API 安全防護:進階限流系統,支援分層與動態限制策略
  5. 密碼安全:強化的密碼處理,包含強度驗證、歷史檢查與安全雜湊

參考資源


上一篇
Day 18: 30天打造SaaS產品後端篇-後端效能優化實作
系列文
30 天打造工作室 SaaS 產品 (後端篇)19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言