iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 19

Day 19: 30天打造SaaS產品前端篇-前端安全防護實作

  • 分享至 

  • xImage
  •  

前情提要

在 Day 18 我們完成了前端效能優化,今天我們要建立前端安全防護體系。對於健身房 SaaS 系統,安全性至關重要 - 從會員個資保護到金流交易,我們將實作全面的前端安全機制,包括 XSS/CSRF 防護、JWT 安全處理、Content Security Policy 配置等企業級安全方案。

XSS 與 CSRF 防護機制

跨站腳本攻擊 (XSS) 防護

// src/utils/SecuritySanitizer.ts
import DOMPurify from 'dompurify';

export class SecuritySanitizer {
  // HTML 內容淨化
  static sanitizeHTML(dirty: string): string {
    return DOMPurify.sanitize(dirty, {
      ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: ['href', 'title', 'alt'],
      FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input'],
      FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
    });
  }

  // URL 參數淨化
  static sanitizeURL(url: string): string {
    try {
      const urlObj = new URL(url);

      // 只允許 HTTPS 和相對路徑
      if (urlObj.protocol !== 'https:' && urlObj.protocol !== 'http:') {
        throw new Error('Invalid protocol');
      }

      // 檢查是否為可信任的域名
      const trustedDomains = ['api.kyo-saas.com', 'localhost'];
      if (!trustedDomains.includes(urlObj.hostname)) {
        throw new Error('Untrusted domain');
      }

      return urlObj.toString();
    } catch (error) {
      console.warn('Invalid URL detected:', url);
      return '/';
    }
  }

  // 用戶輸入淨化
  static sanitizeInput(input: string): string {
    return input
      .replace(/[<>'"&]/g, (match) => {
        const htmlEntities: Record<string, string> = {
          '<': '&lt;',
          '>': '&gt;',
          '"': '&quot;',
          "'": '&#x27;',
          '&': '&amp;'
        };
        return htmlEntities[match];
      })
      .trim();
  }

  // JSON 回應淨化
  static sanitizeAPIResponse(response: any): any {
    if (typeof response === 'string') {
      return this.sanitizeInput(response);
    }

    if (Array.isArray(response)) {
      return response.map(item => this.sanitizeAPIResponse(item));
    }

    if (typeof response === 'object' && response !== null) {
      const sanitized: any = {};
      for (const [key, value] of Object.entries(response)) {
        sanitized[this.sanitizeInput(key)] = this.sanitizeAPIResponse(value);
      }
      return sanitized;
    }

    return response;
  }
}

// React 組件中的安全使用
export const SafeHTML: React.FC<{ content: string; className?: string }> = ({
  content,
  className
}) => {
  const safeContent = SecuritySanitizer.sanitizeHTML(content);

  return (
    <div
      className={className}
      dangerouslySetInnerHTML={{ __html: safeContent }}
    />
  );
};

CSRF 防護實作

// src/utils/CSRFProtection.ts
export class CSRFProtection {
  private static readonly CSRF_TOKEN_KEY = 'csrf_token';
  private static readonly CSRF_HEADER = 'X-CSRF-Token';

  // 產生 CSRF Token
  static generateToken(): string {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
  }

  // 儲存 CSRF Token
  static storeToken(token: string): void {
    sessionStorage.setItem(this.CSRF_TOKEN_KEY, token);

    // 同時設置到 meta tag 供伺服器驗證
    let metaTag = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement;
    if (!metaTag) {
      metaTag = document.createElement('meta');
      metaTag.name = 'csrf-token';
      document.head.appendChild(metaTag);
    }
    metaTag.content = token;
  }

  // 取得 CSRF Token
  static getToken(): string | null {
    return sessionStorage.getItem(this.CSRF_TOKEN_KEY);
  }

  // 驗證 Referer Header
  static validateReferer(allowedOrigins: string[]): boolean {
    const referer = document.referrer;
    if (!referer) return false;

    try {
      const refererURL = new URL(referer);
      return allowedOrigins.some(origin => {
        const originURL = new URL(origin);
        return refererURL.origin === originURL.origin;
      });
    } catch {
      return false;
    }
  }

  // Double Submit Cookie 模式
  static setupDoubleSubmitCookie(): void {
    const token = this.generateToken();

    // 設置 Cookie (HttpOnly=false 讓前端可讀取)
    document.cookie = `csrf_token=${token}; Secure; SameSite=Strict; Path=/`;

    // 同時儲存到 localStorage 供 AJAX 請求使用
    this.storeToken(token);
  }

  // 驗證 Double Submit
  static validateDoubleSubmit(): boolean {
    const cookieToken = this.getCookieValue('csrf_token');
    const storageToken = this.getToken();

    return cookieToken && storageToken && cookieToken === storageToken;
  }

  private static getCookieValue(name: string): string | null {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
      const [cookieName, cookieValue] = cookie.trim().split('=');
      if (cookieName === name) {
        return cookieValue;
      }
    }
    return null;
  }
}

// Axios 攔截器整合
import axios, { AxiosRequestConfig } from 'axios';

axios.interceptors.request.use((config: AxiosRequestConfig) => {
  // 只對非 GET 請求添加 CSRF 保護
  if (config.method && !['get', 'head', 'options'].includes(config.method.toLowerCase())) {
    const csrfToken = CSRFProtection.getToken();
    if (csrfToken) {
      config.headers = config.headers || {};
      config.headers['X-CSRF-Token'] = csrfToken;
    }

    // 驗證 Double Submit
    if (!CSRFProtection.validateDoubleSubmit()) {
      console.warn('CSRF token validation failed');
      return Promise.reject(new Error('CSRF protection failed'));
    }
  }

  return config;
});

Content Security Policy 配置

CSP 策略設計與實作

// src/utils/ContentSecurityPolicy.ts
export class ContentSecurityPolicy {
  private static readonly CSP_NONCE_KEY = 'csp_nonce';

  // 產生 Nonce
  static generateNonce(): string {
    const array = new Uint8Array(16);
    crypto.getRandomValues(array);
    return btoa(String.fromCharCode(...array));
  }

  // 設置 CSP Nonce
  static setupNonce(): string {
    const nonce = this.generateNonce();
    sessionStorage.setItem(this.CSP_NONCE_KEY, nonce);
    return nonce;
  }

  // 取得 CSP Nonce
  static getNonce(): string | null {
    return sessionStorage.getItem(this.CSP_NONCE_KEY);
  }

  // 動態建立 CSP 策略
  static buildCSPPolicy(environment: 'development' | 'production'): string {
    const nonce = this.getNonce();

    const basePolicy = {
      'default-src': ["'self'"],
      'script-src': [
        "'self'",
        nonce ? `'nonce-${nonce}'` : null,
        environment === 'development' ? "'unsafe-eval'" : null,
        'https://cdnjs.cloudflare.com',
        'https://www.google-analytics.com'
      ].filter(Boolean),
      'style-src': [
        "'self'",
        "'unsafe-inline'", // CSS-in-JS 需要
        'https://fonts.googleapis.com'
      ],
      'font-src': [
        "'self'",
        'https://fonts.gstatic.com',
        'data:'
      ],
      'img-src': [
        "'self'",
        'data:',
        'blob:',
        'https://images.kyo-saas.com',
        'https://cdn.kyo-saas.com'
      ],
      'connect-src': [
        "'self'",
        'https://api.kyo-saas.com',
        'wss://api.kyo-saas.com',
        environment === 'development' ? 'ws://localhost:*' : null,
        environment === 'development' ? 'http://localhost:*' : null
      ].filter(Boolean),
      'frame-src': [
        "'none'"
      ],
      'object-src': [
        "'none'"
      ],
      'base-uri': [
        "'self'"
      ],
      'form-action': [
        "'self'"
      ],
      'frame-ancestors': [
        "'none'"
      ],
      'upgrade-insecure-requests': environment === 'production' ? [] : null
    };

    // 建構 CSP 字串
    const cspString = Object.entries(basePolicy)
      .filter(([_, value]) => value !== null)
      .map(([directive, sources]) => {
        if (Array.isArray(sources) && sources.length > 0) {
          return `${directive} ${sources.join(' ')}`;
        } else if (sources === []) {
          return directive;
        }
        return null;
      })
      .filter(Boolean)
      .join('; ');

    return cspString;
  }

  // 設置 CSP Meta Tag
  static setCSPMetaTag(environment: 'development' | 'production'): void {
    const cspPolicy = this.buildCSPPolicy(environment);

    let metaTag = document.querySelector('meta[http-equiv="Content-Security-Policy"]') as HTMLMetaElement;
    if (!metaTag) {
      metaTag = document.createElement('meta');
      metaTag.httpEquiv = 'Content-Security-Policy';
      document.head.appendChild(metaTag);
    }

    metaTag.content = cspPolicy;
  }

  // CSP 違規報告處理
  static setupViolationReporting(): void {
    document.addEventListener('securitypolicyviolation', (event) => {
      const violation = {
        documentURI: event.documentURI,
        violatedDirective: event.violatedDirective,
        blockedURI: event.blockedURI,
        lineNumber: event.lineNumber,
        columnNumber: event.columnNumber,
        sourceFile: event.sourceFile,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent
      };

      // 發送違規報告到後端
      this.reportViolation(violation);
    });
  }

  private static async reportViolation(violation: any): Promise<void> {
    try {
      await fetch('/api/security/csp-violation', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(violation)
      });
    } catch (error) {
      console.error('Failed to report CSP violation:', error);
    }
  }
}

// 在應用程式啟動時初始化 CSP
export const initializeCSP = () => {
  const environment = process.env.NODE_ENV as 'development' | 'production';

  // 設置 Nonce
  const nonce = ContentSecurityPolicy.setupNonce();

  // 設置 CSP 策略
  ContentSecurityPolicy.setCSPMetaTag(environment);

  // 設置違規報告
  ContentSecurityPolicy.setupViolationReporting();

  return nonce;
};

JWT 安全處理與儲存

安全的 Token 管理系統

// src/utils/SecureTokenManager.ts
interface TokenData {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
  tokenType: string;
}

export class SecureTokenManager {
  private static readonly ACCESS_TOKEN_KEY = 'access_token';
  private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
  private static readonly TOKEN_EXPIRY_KEY = 'token_expiry';
  private static readonly ENCRYPTION_KEY = 'user_session_key';

  // 加密 Token
  private static async encryptToken(token: string): Promise<string> {
    const encoder = new TextEncoder();
    const data = encoder.encode(token);

    // 使用 Web Crypto API 進行加密
    const key = await crypto.subtle.importKey(
      'raw',
      encoder.encode(this.getEncryptionKey()),
      { name: 'AES-GCM' },
      false,
      ['encrypt']
    );

    const iv = crypto.getRandomValues(new Uint8Array(12));
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      key,
      data
    );

    // 將 IV 和加密數據組合
    const combined = new Uint8Array(iv.length + encrypted.byteLength);
    combined.set(iv);
    combined.set(new Uint8Array(encrypted), iv.length);

    return btoa(String.fromCharCode(...combined));
  }

  // 解密 Token
  private static async decryptToken(encryptedToken: string): Promise<string> {
    try {
      const combined = new Uint8Array(
        atob(encryptedToken).split('').map(char => char.charCodeAt(0))
      );

      const iv = combined.slice(0, 12);
      const encrypted = combined.slice(12);

      const encoder = new TextEncoder();
      const key = await crypto.subtle.importKey(
        'raw',
        encoder.encode(this.getEncryptionKey()),
        { name: 'AES-GCM' },
        false,
        ['decrypt']
      );

      const decrypted = await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv },
        key,
        encrypted
      );

      return new TextDecoder().decode(decrypted);
    } catch (error) {
      console.error('Token decryption failed:', error);
      this.clearTokens();
      throw new Error('Invalid token');
    }
  }

  // 產生加密金鑰 (基於用戶會話)
  private static getEncryptionKey(): string {
    let key = sessionStorage.getItem(this.ENCRYPTION_KEY);
    if (!key) {
      key = this.generateRandomKey();
      sessionStorage.setItem(this.ENCRYPTION_KEY, key);
    }
    return key;
  }

  private static generateRandomKey(): string {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
  }

  // 儲存 Token (加密)
  static async storeTokens(tokenData: TokenData): Promise<void> {
    try {
      const encryptedAccessToken = await this.encryptToken(tokenData.accessToken);
      const encryptedRefreshToken = await this.encryptToken(tokenData.refreshToken);

      // 使用 sessionStorage 而非 localStorage 增加安全性
      sessionStorage.setItem(this.ACCESS_TOKEN_KEY, encryptedAccessToken);
      sessionStorage.setItem(this.REFRESH_TOKEN_KEY, encryptedRefreshToken);
      sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, tokenData.expiresAt.toString());

      // 設置自動清理
      this.scheduleTokenCleanup(tokenData.expiresAt);
    } catch (error) {
      console.error('Failed to store tokens:', error);
      throw new Error('Token storage failed');
    }
  }

  // 取得 Access Token
  static async getAccessToken(): Promise<string | null> {
    try {
      const encryptedToken = sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
      if (!encryptedToken) return null;

      // 檢查是否過期
      if (this.isTokenExpired()) {
        await this.refreshTokens();
        return this.getAccessToken();
      }

      return await this.decryptToken(encryptedToken);
    } catch (error) {
      console.error('Failed to get access token:', error);
      return null;
    }
  }

  // 取得 Refresh Token
  static async getRefreshToken(): Promise<string | null> {
    try {
      const encryptedToken = sessionStorage.getItem(this.REFRESH_TOKEN_KEY);
      if (!encryptedToken) return null;

      return await this.decryptToken(encryptedToken);
    } catch (error) {
      console.error('Failed to get refresh token:', error);
      return null;
    }
  }

  // 檢查 Token 是否過期
  static isTokenExpired(): boolean {
    const expiryTime = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
    if (!expiryTime) return true;

    const now = Date.now();
    const expiry = parseInt(expiryTime, 10);

    // 提前 5 分鐘視為過期,確保有足夠時間刷新
    return now >= (expiry - 5 * 60 * 1000);
  }

  // 刷新 Token
  static async refreshTokens(): Promise<boolean> {
    try {
      const refreshToken = await this.getRefreshToken();
      if (!refreshToken) {
        this.clearTokens();
        return false;
      }

      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ refreshToken })
      });

      if (!response.ok) {
        this.clearTokens();
        return false;
      }

      const newTokenData = await response.json();
      await this.storeTokens(newTokenData);

      return true;
    } catch (error) {
      console.error('Token refresh failed:', error);
      this.clearTokens();
      return false;
    }
  }

  // 清除所有 Token
  static clearTokens(): void {
    sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
    sessionStorage.removeItem(this.REFRESH_TOKEN_KEY);
    sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
    sessionStorage.removeItem(this.ENCRYPTION_KEY);
  }

  // 排程 Token 清理
  private static scheduleTokenCleanup(expiresAt: number): void {
    const now = Date.now();
    const delay = expiresAt - now;

    if (delay > 0) {
      setTimeout(() => {
        this.clearTokens();
      }, delay);
    }
  }

  // JWT Payload 解析 (不驗證簽名,僅用於前端邏輯)
  static parseJWTPayload(token: string): any {
    try {
      const parts = token.split('.');
      if (parts.length !== 3) {
        throw new Error('Invalid JWT format');
      }

      const payload = parts[1];
      const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
      return JSON.parse(decoded);
    } catch (error) {
      console.error('JWT parse error:', error);
      return null;
    }
  }

  // 取得用戶資訊 (從 JWT)
  static async getUserInfo(): Promise<any> {
    const token = await this.getAccessToken();
    if (!token) return null;

    const payload = this.parseJWTPayload(token);
    return payload ? {
      userId: payload.sub,
      email: payload.email,
      roles: payload.roles || [],
      gymId: payload.gymId,
      permissions: payload.permissions || []
    } : null;
  }
}

前端權限控制與路由守衛

基於角色的權限控制系統

// src/utils/PermissionManager.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',

  // 課程管理
  MANAGE_COURSES = 'manage_courses',
  VIEW_COURSES = 'view_courses',

  // 財務管理
  MANAGE_BILLING = 'manage_billing',
  VIEW_BILLING = 'view_billing',

  // 系統管理
  MANAGE_USERS = 'manage_users',
  SYSTEM_CONFIG = 'system_config'
}

export class PermissionManager {
  private static rolePermissions: Record<Role, Permission[]> = {
    [Role.SUPER_ADMIN]: [
      Permission.MANAGE_GYM,
      Permission.VIEW_GYM_ANALYTICS,
      Permission.MANAGE_MEMBERS,
      Permission.VIEW_MEMBERS,
      Permission.MANAGE_COURSES,
      Permission.VIEW_COURSES,
      Permission.MANAGE_BILLING,
      Permission.VIEW_BILLING,
      Permission.MANAGE_USERS,
      Permission.SYSTEM_CONFIG
    ],
    [Role.GYM_OWNER]: [
      Permission.MANAGE_GYM,
      Permission.VIEW_GYM_ANALYTICS,
      Permission.MANAGE_MEMBERS,
      Permission.VIEW_MEMBERS,
      Permission.MANAGE_COURSES,
      Permission.VIEW_COURSES,
      Permission.MANAGE_BILLING,
      Permission.VIEW_BILLING,
      Permission.MANAGE_USERS
    ],
    [Role.GYM_MANAGER]: [
      Permission.VIEW_GYM_ANALYTICS,
      Permission.MANAGE_MEMBERS,
      Permission.VIEW_MEMBERS,
      Permission.MANAGE_COURSES,
      Permission.VIEW_COURSES,
      Permission.VIEW_BILLING
    ],
    [Role.TRAINER]: [
      Permission.VIEW_MEMBERS,
      Permission.MANAGE_COURSES,
      Permission.VIEW_COURSES
    ],
    [Role.MEMBER]: [
      Permission.VIEW_COURSES
    ]
  };

  // 檢查用戶是否有特定權限
  static async hasPermission(permission: Permission): Promise<boolean> {
    try {
      const userInfo = await SecureTokenManager.getUserInfo();
      if (!userInfo) return false;

      // 直接檢查權限列表
      if (userInfo.permissions?.includes(permission)) {
        return true;
      }

      // 根據角色檢查權限
      if (userInfo.roles?.length > 0) {
        return userInfo.roles.some((role: Role) =>
          this.rolePermissions[role]?.includes(permission)
        );
      }

      return false;
    } catch (error) {
      console.error('Permission check failed:', error);
      return false;
    }
  }

  // 檢查用戶是否有任一權限
  static async hasAnyPermission(permissions: Permission[]): Promise<boolean> {
    for (const permission of permissions) {
      if (await this.hasPermission(permission)) {
        return true;
      }
    }
    return false;
  }

  // 檢查用戶是否有所有權限
  static async hasAllPermissions(permissions: Permission[]): Promise<boolean> {
    for (const permission of permissions) {
      if (!(await this.hasPermission(permission))) {
        return false;
      }
    }
    return true;
  }

  // 檢查用戶角色
  static async hasRole(role: Role): Promise<boolean> {
    try {
      const userInfo = await SecureTokenManager.getUserInfo();
      return userInfo?.roles?.includes(role) || false;
    } catch (error) {
      console.error('Role check failed:', error);
      return false;
    }
  }

  // 取得用戶所有權限
  static async getUserPermissions(): Promise<Permission[]> {
    try {
      const userInfo = await SecureTokenManager.getUserInfo();
      if (!userInfo) return [];

      const permissions = new Set<Permission>();

      // 加入直接權限
      if (userInfo.permissions) {
        userInfo.permissions.forEach((p: Permission) => permissions.add(p));
      }

      // 加入角色權限
      if (userInfo.roles) {
        userInfo.roles.forEach((role: Role) => {
          this.rolePermissions[role]?.forEach(p => permissions.add(p));
        });
      }

      return Array.from(permissions);
    } catch (error) {
      console.error('Failed to get user permissions:', error);
      return [];
    }
  }
}

// 權限檢查 Hook
import { useState, useEffect } from 'react';

export const usePermission = (permission: Permission) => {
  const [hasPermission, setHasPermission] = useState<boolean>(false);
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    const checkPermission = async () => {
      setLoading(true);
      try {
        const result = await PermissionManager.hasPermission(permission);
        setHasPermission(result);
      } catch (error) {
        console.error('Permission check error:', error);
        setHasPermission(false);
      } finally {
        setLoading(false);
      }
    };

    checkPermission();
  }, [permission]);

  return { hasPermission, loading };
};

// 多權限檢查 Hook
export const usePermissions = (permissions: Permission[]) => {
  const [permissionMap, setPermissionMap] = useState<Record<Permission, boolean>>({});
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    const checkPermissions = async () => {
      setLoading(true);
      try {
        const results: Record<Permission, boolean> = {};

        await Promise.all(
          permissions.map(async (permission) => {
            results[permission] = await PermissionManager.hasPermission(permission);
          })
        );

        setPermissionMap(results);
      } catch (error) {
        console.error('Permissions check error:', error);
        setPermissionMap({});
      } finally {
        setLoading(false);
      }
    };

    checkPermissions();
  }, [permissions]);

  return { permissionMap, loading };
};

路由守衛實作

// src/components/RouteGuard.tsx
import React, { useEffect, useState } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Permission, PermissionManager, Role } from '@/utils/PermissionManager';
import { SecureTokenManager } from '@/utils/SecureTokenManager';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';

interface RouteGuardProps {
  children: React.ReactNode;
  requiredPermissions?: Permission[];
  requiredRoles?: Role[];
  requireAny?: boolean; // 是否只需滿足任一條件
  redirectTo?: string;
}

export const RouteGuard: React.FC<RouteGuardProps> = ({
  children,
  requiredPermissions = [],
  requiredRoles = [],
  requireAny = false,
  redirectTo = '/login'
}) => {
  const [loading, setLoading] = useState(true);
  const [hasAccess, setHasAccess] = useState(false);
  const location = useLocation();

  useEffect(() => {
    const checkAccess = async () => {
      setLoading(true);

      try {
        // 檢查是否已登入
        const token = await SecureTokenManager.getAccessToken();
        if (!token) {
          setHasAccess(false);
          setLoading(false);
          return;
        }

        // 如果沒有設定權限要求,則允許已登入用戶訪問
        if (requiredPermissions.length === 0 && requiredRoles.length === 0) {
          setHasAccess(true);
          setLoading(false);
          return;
        }

        let permissionCheck = false;
        let roleCheck = false;

        // 檢查權限
        if (requiredPermissions.length > 0) {
          if (requireAny) {
            permissionCheck = await PermissionManager.hasAnyPermission(requiredPermissions);
          } else {
            permissionCheck = await PermissionManager.hasAllPermissions(requiredPermissions);
          }
        } else {
          permissionCheck = true; // 沒有權限要求則視為通過
        }

        // 檢查角色
        if (requiredRoles.length > 0) {
          if (requireAny) {
            roleCheck = await Promise.all(
              requiredRoles.map(role => PermissionManager.hasRole(role))
            ).then(results => results.some(result => result));
          } else {
            roleCheck = await Promise.all(
              requiredRoles.map(role => PermissionManager.hasRole(role))
            ).then(results => results.every(result => result));
          }
        } else {
          roleCheck = true; // 沒有角色要求則視為通過
        }

        // 根據 requireAny 決定最終結果
        if (requireAny) {
          setHasAccess(permissionCheck || roleCheck);
        } else {
          setHasAccess(permissionCheck && roleCheck);
        }

      } catch (error) {
        console.error('Route guard check failed:', error);
        setHasAccess(false);
      } finally {
        setLoading(false);
      }
    };

    checkAccess();
  }, [requiredPermissions, requiredRoles, requireAny, location.pathname]);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <LoadingSpinner />
      </div>
    );
  }

  if (!hasAccess) {
    return <Navigate to={redirectTo} state={{ from: location }} replace />;
  }

  return <>{children}</>;
};

// 條件性渲染組件
interface ConditionalRenderProps {
  children: React.ReactNode;
  permission?: Permission;
  role?: Role;
  permissions?: Permission[];
  roles?: Role[];
  requireAny?: boolean;
  fallback?: React.ReactNode;
}

export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
  children,
  permission,
  role,
  permissions = [],
  roles = [],
  requireAny = false,
  fallback = null
}) => {
  const [hasAccess, setHasAccess] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const checkAccess = async () => {
      try {
        let permissionCheck = true;
        let roleCheck = true;

        // 單一權限檢查
        if (permission) {
          permissionCheck = await PermissionManager.hasPermission(permission);
        }

        // 多權限檢查
        if (permissions.length > 0) {
          if (requireAny) {
            permissionCheck = await PermissionManager.hasAnyPermission(permissions);
          } else {
            permissionCheck = await PermissionManager.hasAllPermissions(permissions);
          }
        }

        // 單一角色檢查
        if (role) {
          roleCheck = await PermissionManager.hasRole(role);
        }

        // 多角色檢查
        if (roles.length > 0) {
          if (requireAny) {
            roleCheck = await Promise.all(
              roles.map(r => PermissionManager.hasRole(r))
            ).then(results => results.some(result => result));
          } else {
            roleCheck = await Promise.all(
              roles.map(r => PermissionManager.hasRole(r))
            ).then(results => results.every(result => result));
          }
        }

        setHasAccess(permissionCheck && roleCheck);
      } catch (error) {
        console.error('Conditional render check failed:', error);
        setHasAccess(false);
      } finally {
        setLoading(false);
      }
    };

    checkAccess();
  }, [permission, role, permissions, roles, requireAny]);

  if (loading) {
    return null; // 或者返回載入狀態
  }

  return hasAccess ? <>{children}</> : <>{fallback}</>;
};

安全的 API 通訊

加密通訊與請求簽名

// src/utils/SecureAPIClient.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { SecureTokenManager } from './SecureTokenManager';
import { CSRFProtection } from './CSRFProtection';
import { SecuritySanitizer } from './SecuritySanitizer';

export class SecureAPIClient {
  private static instance: SecureAPIClient;
  private axiosInstance: AxiosInstance;
  private readonly baseURL: string;

  constructor() {
    this.baseURL = process.env.REACT_APP_API_BASE_URL || 'https://api.kyo-saas.com';
    this.axiosInstance = this.createSecureInstance();
    this.setupInterceptors();
  }

  static getInstance(): SecureAPIClient {
    if (!SecureAPIClient.instance) {
      SecureAPIClient.instance = new SecureAPIClient();
    }
    return SecureAPIClient.instance;
  }

  private createSecureInstance(): AxiosInstance {
    return axios.create({
      baseURL: this.baseURL,
      timeout: 30000,
      withCredentials: true,
      headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
        'Accept': 'application/json'
      }
    });
  }

  private setupInterceptors(): void {
    // 請求攔截器
    this.axiosInstance.interceptors.request.use(
      async (config: AxiosRequestConfig) => {
        // 添加認證 Token
        const token = await SecureTokenManager.getAccessToken();
        if (token) {
          config.headers = config.headers || {};
          config.headers.Authorization = `Bearer ${token}`;
        }

        // 添加 CSRF 保護
        if (config.method && !['get', 'head', 'options'].includes(config.method.toLowerCase())) {
          const csrfToken = CSRFProtection.getToken();
          if (csrfToken) {
            config.headers = config.headers || {};
            config.headers['X-CSRF-Token'] = csrfToken;
          }
        }

        // 添加請求簽名
        if (config.data) {
          const signature = await this.signRequest(config.data);
          config.headers = config.headers || {};
          config.headers['X-Request-Signature'] = signature;
        }

        // 添加時間戳防重放攻擊
        config.headers = config.headers || {};
        config.headers['X-Timestamp'] = Date.now().toString();

        // 淨化請求數據
        if (config.data) {
          config.data = SecuritySanitizer.sanitizeAPIResponse(config.data);
        }

        return config;
      },
      (error) => {
        console.error('Request interceptor error:', error);
        return Promise.reject(error);
      }
    );

    // 回應攔截器
    this.axiosInstance.interceptors.response.use(
      (response: AxiosResponse) => {
        // 淨化回應數據
        if (response.data) {
          response.data = SecuritySanitizer.sanitizeAPIResponse(response.data);
        }

        return response;
      },
      async (error) => {
        const originalRequest = error.config;

        // Token 過期處理
        if (error.response?.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true;

          const refreshSuccess = await SecureTokenManager.refreshTokens();
          if (refreshSuccess) {
            const newToken = await SecureTokenManager.getAccessToken();
            originalRequest.headers.Authorization = `Bearer ${newToken}`;
            return this.axiosInstance(originalRequest);
          } else {
            // 刷新失敗,重定向到登入頁面
            window.location.href = '/login';
            return Promise.reject(error);
          }
        }

        // CSRF 錯誤處理
        if (error.response?.status === 403 && error.response?.data?.error === 'CSRF token mismatch') {
          CSRFProtection.setupDoubleSubmitCookie();
          return this.axiosInstance(originalRequest);
        }

        // 記錄安全相關錯誤
        this.logSecurityError(error);

        return Promise.reject(error);
      }
    );
  }

  // 請求簽名
  private async signRequest(data: any): Promise<string> {
    const payload = JSON.stringify(data);
    const encoder = new TextEncoder();
    const payloadBytes = encoder.encode(payload);

    // 使用 HMAC-SHA256 簽名
    const key = await this.getSigningKey();
    const signature = await crypto.subtle.sign(
      'HMAC',
      key,
      payloadBytes
    );

    return Array.from(new Uint8Array(signature))
      .map(byte => byte.toString(16).padStart(2, '0'))
      .join('');
  }

  private async getSigningKey(): Promise<CryptoKey> {
    const keyMaterial = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(this.getClientSecret()),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );
    return keyMaterial;
  }

  private getClientSecret(): string {
    // 在實際應用中,這應該是從安全的來源獲取
    return process.env.REACT_APP_CLIENT_SECRET || 'default-secret';
  }

  // 記錄安全錯誤
  private logSecurityError(error: any): void {
    const securityError = {
      timestamp: new Date().toISOString(),
      url: error.config?.url,
      method: error.config?.method,
      status: error.response?.status,
      message: error.message,
      userAgent: navigator.userAgent,
      referer: document.referrer
    };

    // 發送到安全監控系統
    this.reportSecurityIncident(securityError);
  }

  private async reportSecurityIncident(incident: any): Promise<void> {
    try {
      await fetch('/api/security/incident', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(incident)
      });
    } catch (error) {
      console.error('Failed to report security incident:', error);
    }
  }

  // 公開 API 方法
  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.axiosInstance.get<T>(url, config);
    return response.data;
  }

  async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.axiosInstance.post<T>(url, data, config);
    return response.data;
  }

  async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.axiosInstance.put<T>(url, data, config);
    return response.data;
  }

  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.axiosInstance.delete<T>(url, config);
    return response.data;
  }
}

// API Client 單例
export const apiClient = SecureAPIClient.getInstance();

綜合安全初始化

// src/security/SecurityManager.ts
import { ContentSecurityPolicy, initializeCSP } from '@/utils/ContentSecurityPolicy';
import { CSRFProtection } from '@/utils/CSRFProtection';
import { SecureTokenManager } from '@/utils/SecureTokenManager';

export class SecurityManager {
  static async initialize(): Promise<void> {
    try {
      console.log('Initializing security systems...');

      // 1. 初始化 CSP
      const nonce = initializeCSP();
      console.log('CSP initialized with nonce:', nonce.substring(0, 8) + '...');

      // 2. 設置 CSRF 保護
      CSRFProtection.setupDoubleSubmitCookie();
      console.log('CSRF protection enabled');

      // 3. 驗證現有 Token
      const isValidToken = await this.validateExistingSession();
      console.log('Token validation:', isValidToken ? 'valid' : 'invalid');

      // 4. 設置安全事件監聽
      this.setupSecurityEventListeners();
      console.log('Security event listeners configured');

      // 5. 定期安全檢查
      this.startSecurityMonitoring();
      console.log('Security monitoring started');

      console.log('Security initialization complete');
    } catch (error) {
      console.error('Security initialization failed:', error);
      throw error;
    }
  }

  private static async validateExistingSession(): Promise<boolean> {
    try {
      const token = await SecureTokenManager.getAccessToken();
      if (!token) return false;

      const userInfo = await SecureTokenManager.getUserInfo();
      return !!userInfo;
    } catch (error) {
      console.error('Session validation failed:', error);
      SecureTokenManager.clearTokens();
      return false;
    }
  }

  private static setupSecurityEventListeners(): void {
    // 監聽頁面隱藏事件,清理敏感資料
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        // 頁面隱藏時清理敏感 DOM 內容
        this.clearSensitiveData();
      }
    });

    // 監聽開發者工具
    let devtools = false;
    setInterval(() => {
      if (!devtools && (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200)) {
        devtools = true;
        console.warn('Developer tools detected');
        // 可以選擇在生產環境中採取額外安全措施
      }
    }, 500);

    // 監聽複製事件,防止敏感資料外洩
    document.addEventListener('copy', (event) => {
      const selection = window.getSelection()?.toString();
      if (selection && this.containsSensitiveData(selection)) {
        event.preventDefault();
        console.warn('Sensitive data copy prevented');
      }
    });
  }

  private static clearSensitiveData(): void {
    // 清理表單中的敏感資料
    const sensitiveInputs = document.querySelectorAll('input[type="password"], input[data-sensitive]');
    sensitiveInputs.forEach((input: Element) => {
      (input as HTMLInputElement).value = '';
    });
  }

  private static containsSensitiveData(text: string): boolean {
    const sensitivePatterns = [
      /\b\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b/, // 信用卡號
      /\b\d{3}-\d{2}-\d{4}\b/, // 社會安全號碼格式
      /password/i,
      /token/i,
      /secret/i
    ];

    return sensitivePatterns.some(pattern => pattern.test(text));
  }

  private static startSecurityMonitoring(): void {
    // 每 5 分鐘檢查一次安全狀態
    setInterval(async () => {
      try {
        // 檢查 Token 有效性
        const tokenValid = await this.validateExistingSession();
        if (!tokenValid) {
          console.warn('Invalid session detected, redirecting to login');
          window.location.href = '/login';
        }

        // 檢查 CSRF Token
        if (!CSRFProtection.validateDoubleSubmit()) {
          console.warn('CSRF validation failed, refreshing token');
          CSRFProtection.setupDoubleSubmitCookie();
        }
      } catch (error) {
        console.error('Security monitoring error:', error);
      }
    }, 5 * 60 * 1000);
  }

  static async cleanup(): Promise<void> {
    console.log('Cleaning up security systems...');
    SecureTokenManager.clearTokens();
    CSRFProtection.generateToken(); // 重新產生新的 CSRF Token
    console.log('Security cleanup complete');
  }
}

今日總結

今天我們建立了全面的前端安全防護體系:

  1. XSS/CSRF 防護:HTML 淨化、URL 驗證、Double Submit Cookie 模式
  2. CSP 策略:動態 CSP 生成、Nonce 機制、違規報告系統
  3. JWT 安全處理:加密儲存、自動刷新、安全解析機制
  4. 權限控制:RBAC 角色系統、路由守衛、條件性渲染
  5. API 安全:請求簽名、自動重試、安全錯誤處理

參考資源


上一篇
Day 18: 30天打造SaaS產品前端篇-前端效能優化方法
下一篇
Day 20: 30天打造SaaS產品前端篇 - 第20天React架構盤點與實踐驗證
系列文
30 天製作工作室 SaaS 產品 (前端篇)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言