經過 Day 25 的用戶認證系統建置,我們已經有了完整的登入/註冊機制。今天我們要為 Kyo System 增加企業級的多因素認證 (MFA) 與帳號安全強化功能。在現代 SaaS 產品中,單純的密碼認證早就不夠安全,我們需要 MFA、裝置信任管理、登入歷史追蹤等多層防護,來保護用戶的敏感資料。
/**
 * 多因素認證 (MFA) 完整架構
 *
 * ┌─────────────────────────────────────────────┐
 * │         MFA Authentication Flow             │
 * └─────────────────────────────────────────────┘
 *
 * 第一階段:密碼認證
 *    ↓
 * 第二階段:選擇 MFA 方式
 *    ├─ TOTP (Time-based OTP)
 *    │  └─ Google Authenticator / Authy
 *    ├─ SMS OTP
 *    │  └─ 手機簡訊驗證碼
 *    └─ 備份碼
 *       └─ 一次性恢復碼
 *    ↓
 * 驗證成功 → 建立會話
 *    ├─ 記住此裝置(30天免 MFA)
 *    └─ 記錄登入歷史
 *
 * MFA 設定流程:
 * 1. 掃描 QR Code
 * 2. 輸入驗證碼確認
 * 3. 保存備份碼
 * 4. MFA 啟用
 *
 * 安全特性:
 * ✅ TOTP 基於 RFC 6238 標準
 * ✅ 備份碼加密儲存
 * ✅ 裝置指紋識別
 * ✅ 異常登入偵測
 * ✅ 登入歷史審計
 * ✅ 會話管理
 */
// src/pages/Security/MFASetup.tsx
import { useState, useEffect } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stepper,
  Button,
  Group,
  Stack,
  PinInput,
  Alert,
  Code,
  CopyButton,
  ActionIcon,
  Tooltip,
  Center,
  Box,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
  IconShield,
  IconCheck,
  IconCopy,
  IconAlertTriangle,
  IconDownload,
} from '@tabler/icons-react';
import { QRCodeSVG } from 'qrcode.react';
import { authenticator } from 'otpauth';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
interface MFASetupData {
  secret: string;
  qrCodeUrl: string;
  backupCodes: string[];
}
export function MFASetupPage() {
  const { user } = useAuth();
  const [active, setActive] = useState(0);
  const [loading, setLoading] = useState(false);
  const [mfaData, setMfaData] = useState<MFASetupData | null>(null);
  const [verificationCode, setVerificationCode] = useState('');
  /**
   * 步驟 1: 生成 TOTP Secret 與 QR Code
   */
  const initializeMFA = async () => {
    try {
      setLoading(true);
      // 呼叫 API 生成 MFA Secret
      const response = await fetch('/api/auth/mfa/initialize', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
      });
      const data = await response.json();
      setMfaData({
        secret: data.secret,
        qrCodeUrl: data.qrCodeUrl,
        backupCodes: data.backupCodes,
      });
      setActive(1);
    } catch (error: any) {
      notifications.show({
        title: '初始化失敗',
        message: error.message || '無法生成 MFA 設定',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };
  /**
   * 步驟 2: 驗證 TOTP 碼
   */
  const verifyTOTP = async () => {
    if (verificationCode.length !== 6) {
      notifications.show({
        title: '驗證碼錯誤',
        message: '請輸入 6 位數驗證碼',
        color: 'red',
      });
      return;
    }
    try {
      setLoading(true);
      const response = await fetch('/api/auth/mfa/verify', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          code: verificationCode,
          secret: mfaData?.secret,
        }),
      });
      if (!response.ok) {
        throw new Error('驗證碼錯誤');
      }
      notifications.show({
        title: '驗證成功',
        message: 'TOTP 驗證成功',
        color: 'green',
      });
      setActive(2);
    } catch (error: any) {
      notifications.show({
        title: '驗證失敗',
        message: error.message || '驗證碼錯誤,請重試',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };
  /**
   * 步驟 3: 啟用 MFA
   */
  const enableMFA = async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/auth/mfa/enable', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          secret: mfaData?.secret,
        }),
      });
      if (!response.ok) {
        throw new Error('啟用失敗');
      }
      notifications.show({
        title: 'MFA 已啟用',
        message: '您的帳號已受到雙因素驗證保護',
        color: 'green',
        icon: <IconShield />,
      });
      setActive(3);
    } catch (error: any) {
      notifications.show({
        title: '啟用失敗',
        message: error.message || '無法啟用 MFA',
        color: 'red',
      });
    } finally {
      setLoading(false);
    }
  };
  /**
   * 下載備份碼
   */
  const downloadBackupCodes = () => {
    if (!mfaData?.backupCodes) return;
    const content = `Kyo System - MFA 備份碼\n\n` +
      `帳號: ${user?.email}\n` +
      `生成時間: ${new Date().toLocaleString()}\n\n` +
      `備份碼(每個只能使用一次):\n` +
      mfaData.backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n') +
      `\n\n請妥善保管此文件,不要分享給任何人。`;
    const blob = new Blob([content], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `kyo-mfa-backup-codes-${Date.now()}.txt`;
    link.click();
    URL.revokeObjectURL(url);
    notifications.show({
      title: '備份碼已下載',
      message: '請妥善保管此文件',
      color: 'blue',
    });
  };
  return (
    <Container size={600} my={40}>
      <Paper radius="md" p="xl" withBorder>
        <Title order={2} mb="xl">
          設定雙因素驗證 (MFA)
        </Title>
        <Stepper active={active} onStepClick={setActive} breakpoint="sm">
          {/* 步驟 1: 介紹 */}
          <Stepper.Step label="開始設定" description="了解 MFA">
            <Stack spacing="md" py="xl">
              <Alert icon={<IconShield size={16} />} title="為什麼需要 MFA?" color="blue">
                雙因素驗證能在密碼外加上額外的安全層,即使密碼被盜,攻擊者仍無法登入您的帳號。
              </Alert>
              <Text>
                <strong>MFA 如何運作?</strong>
              </Text>
              <Text size="sm" color="dimmed">
                1. 使用 Google Authenticator 或 Authy 等驗證應用程式
                <br />
                2. 掃描 QR Code 綁定您的帳號
                <br />
                3. 登入時輸入驗證應用程式產生的 6 位數驗證碼
                <br />
                4. 每 30 秒驗證碼會自動更新
              </Text>
              <Button
                onClick={initializeMFA}
                loading={loading}
                leftIcon={<IconShield size={18} />}
              >
                開始設定 MFA
              </Button>
            </Stack>
          </Stepper.Step>
          {/* 步驟 2: 掃描 QR Code */}
          <Stepper.Step label="掃描 QR Code" description="使用驗證應用程式">
            {mfaData && (
              <Stack spacing="md" py="xl">
                <Text>
                  使用 <strong>Google Authenticator</strong> 或 <strong>Authy</strong>
                  掃描此 QR Code
                </Text>
                <Center>
                  <Box p="md" style={{ background: 'white', borderRadius: 8 }}>
                    <QRCodeSVG
                      value={mfaData.qrCodeUrl}
                      size={200}
                      level="H"
                      includeMargin
                    />
                  </Box>
                </Center>
                <Alert icon={<IconAlertTriangle size={16} />} color="yellow">
                  無法掃描?手動輸入此金鑰:
                  <Code block mt="xs">
                    {mfaData.secret}
                  </Code>
                  <Group position="right" mt="xs">
                    <CopyButton value={mfaData.secret}>
                      {({ copied, copy }) => (
                        <Tooltip label={copied ? '已複製' : '複製'}>
                          <ActionIcon onClick={copy} variant="subtle">
                            {copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
                          </ActionIcon>
                        </Tooltip>
                      )}
                    </CopyButton>
                  </Group>
                </Alert>
                <Text>掃描完成後,輸入驗證應用程式顯示的 6 位數驗證碼:</Text>
                <Center>
                  <PinInput
                    length={6}
                    type="number"
                    value={verificationCode}
                    onChange={setVerificationCode}
                    size="lg"
                    placeholder=""
                  />
                </Center>
                <Button
                  onClick={verifyTOTP}
                  loading={loading}
                  disabled={verificationCode.length !== 6}
                  fullWidth
                >
                  驗證並繼續
                </Button>
              </Stack>
            )}
          </Stepper.Step>
          {/* 步驟 3: 保存備份碼 */}
          <Stepper.Step label="保存備份碼" description="重要!">
            {mfaData && (
              <Stack spacing="md" py="xl">
                <Alert icon={<IconAlertTriangle size={16} />} color="orange" title="重要!">
                  以下備份碼用於在無法使用驗證應用程式時恢復存取。
                  每個備份碼只能使用一次,請妥善保管。
                </Alert>
                <Paper p="md" withBorder>
                  <Stack spacing="xs">
                    {mfaData.backupCodes.map((code, index) => (
                      <Group key={index} position="apart">
                        <Code>{code}</Code>
                        <CopyButton value={code}>
                          {({ copied, copy }) => (
                            <Tooltip label={copied ? '已複製' : '複製'}>
                              <ActionIcon onClick={copy} variant="subtle" size="sm">
                                {copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
                              </ActionIcon>
                            </Tooltip>
                          )}
                        </CopyButton>
                      </Group>
                    ))}
                  </Stack>
                </Paper>
                <Button
                  variant="outline"
                  leftIcon={<IconDownload size={18} />}
                  onClick={downloadBackupCodes}
                >
                  下載備份碼
                </Button>
                <Button onClick={enableMFA} loading={loading} fullWidth>
                  我已保存備份碼,啟用 MFA
                </Button>
              </Stack>
            )}
          </Stepper.Step>
          {/* 步驟 4: 完成 */}
          <Stepper.Completed>
            <Stack spacing="md" py="xl" align="center">
              <IconShield size={64} color="green" />
              <Title order={3}>MFA 已成功啟用!</Title>
              <Text color="dimmed" ta="center">
                您的帳號現在受到雙因素驗證保護。
                下次登入時,您需要輸入驗證應用程式產生的驗證碼。
              </Text>
              <Button onClick={() => window.location.href = '/dashboard'}>
                返回 Dashboard
              </Button>
            </Stack>
          </Stepper.Completed>
        </Stepper>
      </Paper>
    </Container>
  );
}
// src/pages/Auth/MFAVerifyPage.tsx
import { useState } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stack,
  PinInput,
  Button,
  Alert,
  Anchor,
  Group,
  Checkbox,
  Center,
} from '@mantine/core';
import { IconShield, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
export function MFAVerifyPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const [code, setCode] = useState('');
  const [loading, setLoading] = useState(false);
  const [trustDevice, setTrustDevice] = useState(false);
  const [showBackupCode, setShowBackupCode] = useState(false);
  // 從前一頁取得暫時 token
  const tempToken = location.state?.tempToken;
  const handleVerify = async () => {
    if (code.length !== 6) {
      notifications.show({
        title: '驗證碼錯誤',
        message: '請輸入 6 位數驗證碼',
        color: 'red',
      });
      return;
    }
    try {
      setLoading(true);
      const response = await fetch('/api/auth/mfa/validate', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          tempToken,
          code,
          trustDevice,
          isBackupCode: showBackupCode,
        }),
      });
      if (!response.ok) {
        throw new Error('驗證碼錯誤');
      }
      const data = await response.json();
      // 儲存 Access Token
      localStorage.setItem('accessToken', data.accessToken);
      // 如果選擇信任此裝置,儲存裝置 token
      if (trustDevice && data.deviceToken) {
        localStorage.setItem('deviceToken', data.deviceToken);
      }
      notifications.show({
        title: '登入成功',
        message: '歡迎回來!',
        color: 'green',
      });
      navigate('/dashboard');
    } catch (error: any) {
      notifications.show({
        title: '驗證失敗',
        message: error.message || '驗證碼錯誤,請重試',
        color: 'red',
      });
      setCode('');
    } finally {
      setLoading(false);
    }
  };
  return (
    <Container size={420} my={80}>
      <Paper radius="md" p="xl" withBorder>
        <Center mb="xl">
          <IconShield size={48} color="blue" />
        </Center>
        <Title order={2} ta="center" mb="md">
          雙因素驗證
        </Title>
        <Text c="dimmed" size="sm" ta="center" mb="xl">
          {showBackupCode
            ? '輸入您的備份碼'
            : '請輸入驗證應用程式顯示的 6 位數驗證碼'
          }
        </Text>
        <Stack spacing="md">
          <Center>
            <PinInput
              length={showBackupCode ? 8 : 6}
              type={showBackupCode ? 'text' : 'number'}
              value={code}
              onChange={setCode}
              size="lg"
              placeholder=""
              oneTimeCode
            />
          </Center>
          <Checkbox
            label="信任此裝置 30 天(不建議在公用電腦上使用)"
            checked={trustDevice}
            onChange={(e) => setTrustDevice(e.currentTarget.checked)}
          />
          <Button
            onClick={handleVerify}
            loading={loading}
            disabled={code.length < (showBackupCode ? 8 : 6)}
            fullWidth
          >
            驗證
          </Button>
          <Group position="center">
            <Anchor
              size="sm"
              onClick={() => setShowBackupCode(!showBackupCode)}
            >
              {showBackupCode ? '使用驗證應用程式' : '使用備份碼'}
            </Anchor>
          </Group>
          {showBackupCode && (
            <Alert icon={<IconAlertCircle size={16} />} color="yellow">
              每個備份碼只能使用一次。使用後請立即重新生成備份碼。
            </Alert>
          )}
        </Stack>
      </Paper>
    </Container>
  );
}
// src/pages/Security/SecuritySettings.tsx
import { useState, useEffect } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  Stack,
  Group,
  Button,
  Badge,
  Switch,
  Timeline,
  Table,
  ActionIcon,
  Modal,
  Alert,
  Divider,
  ThemeIcon,
  Progress,
  Card,
  SimpleGrid,
} from '@mantine/core';
import {
  IconShield,
  IconDevices,
  IconHistory,
  IconKey,
  IconTrash,
  IconMapPin,
  IconClock,
  IconAlertTriangle,
  IconCheck,
  IconX,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
import { formatDistanceToNow } from 'date-fns';
import { zhTW } from 'date-fns/locale';
interface SecurityDevice {
  id: string;
  name: string;
  deviceType: string;
  browser: string;
  os: string;
  ipAddress: string;
  location?: string;
  lastAccessAt: Date;
  isCurrent: boolean;
  trusted: boolean;
}
interface LoginHistory {
  id: string;
  timestamp: Date;
  ipAddress: string;
  location?: string;
  device: string;
  success: boolean;
  mfaUsed: boolean;
}
export function SecuritySettingsPage() {
  const { user } = useAuth();
  const [mfaEnabled, setMfaEnabled] = useState(false);
  const [devices, setDevices] = useState<SecurityDevice[]>([]);
  const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
  const [loading, setLoading] = useState(true);
  const [revokeModalOpen, setRevokeModalOpen] = useState(false);
  const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
  useEffect(() => {
    fetchSecurityData();
  }, []);
  const fetchSecurityData = async () => {
    try {
      setLoading(true);
      const [mfaRes, devicesRes, historyRes] = await Promise.all([
        fetch('/api/auth/mfa/status', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
        fetch('/api/auth/devices', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
        fetch('/api/auth/login-history', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        }),
      ]);
      const mfaData = await mfaRes.json();
      const devicesData = await devicesRes.json();
      const historyData = await historyRes.json();
      setMfaEnabled(mfaData.enabled);
      setDevices(devicesData.devices);
      setLoginHistory(historyData.history);
    } catch (error) {
      console.error('Failed to fetch security data:', error);
    } finally {
      setLoading(false);
    }
  };
  const handleToggleMFA = async () => {
    if (mfaEnabled) {
      // 停用 MFA
      const confirmed = window.confirm('確定要停用雙因素驗證嗎?這會降低您的帳號安全性。');
      if (!confirmed) return;
      try {
        const response = await fetch('/api/auth/mfa/disable', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
          },
        });
        if (response.ok) {
          setMfaEnabled(false);
          notifications.show({
            title: 'MFA 已停用',
            message: '雙因素驗證已關閉',
            color: 'yellow',
          });
        }
      } catch (error) {
        notifications.show({
          title: '操作失敗',
          message: '無法停用 MFA',
          color: 'red',
        });
      }
    } else {
      // 啟用 MFA - 導向設定頁面
      window.location.href = '/security/mfa-setup';
    }
  };
  const handleRevokeDevice = async (deviceId: string) => {
    try {
      const response = await fetch(`/api/auth/devices/${deviceId}`, {
        method: 'DELETE',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
        },
      });
      if (response.ok) {
        setDevices(devices.filter(d => d.id !== deviceId));
        setRevokeModalOpen(false);
        notifications.show({
          title: '裝置已移除',
          message: '該裝置的存取權限已被撤銷',
          color: 'green',
        });
      }
    } catch (error) {
      notifications.show({
        title: '操作失敗',
        message: '無法撤銷裝置',
        color: 'red',
      });
    }
  };
  // 計算安全分數
  const calculateSecurityScore = (): number => {
    let score = 0;
    if (user?.emailVerified) score += 20;
    if (mfaEnabled) score += 40;
    if (devices.filter(d => d.trusted).length <= 3) score += 20;
    const recentLogins = loginHistory.filter(h =>
      h.timestamp > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
    );
    if (recentLogins.length > 0 && recentLogins.every(h => h.success)) score += 20;
    return score;
  };
  const securityScore = calculateSecurityScore();
  const getScoreColor = (score: number): string => {
    if (score >= 80) return 'green';
    if (score >= 60) return 'yellow';
    return 'red';
  };
  return (
    <Container size="lg" my={40}>
      <Stack spacing="xl">
        <div>
          <Title order={2} mb="xs">
            帳號安全
          </Title>
          <Text color="dimmed">管理您的安全設定、裝置與登入歷史</Text>
        </div>
        {/* 安全分數 */}
        <Card withBorder padding="lg">
          <Group position="apart" mb="md">
            <div>
              <Text weight={500}>安全分數</Text>
              <Text size="sm" color="dimmed">
                您的帳號安全等級
              </Text>
            </div>
            <ThemeIcon size="xl" radius="xl" variant="light" color={getScoreColor(securityScore)}>
              <IconShield size={24} />
            </ThemeIcon>
          </Group>
          <Progress
            value={securityScore}
            color={getScoreColor(securityScore)}
            size="lg"
            mb="xs"
          />
          <Text size="sm" color="dimmed">
            {securityScore}/100 分
            {securityScore < 80 && ' - 建議啟用 MFA 以提升安全性'}
          </Text>
        </Card>
        {/* MFA 設定 */}
        <Paper withBorder p="lg">
          <Group position="apart" mb="md">
            <div>
              <Group spacing="xs" mb={4}>
                <Text weight={500}>雙因素驗證 (MFA)</Text>
                {mfaEnabled ? (
                  <Badge color="green" size="sm">已啟用</Badge>
                ) : (
                  <Badge color="red" size="sm">未啟用</Badge>
                )}
              </Group>
              <Text size="sm" color="dimmed">
                為您的帳號增加額外的安全層
              </Text>
            </div>
            <Switch
              checked={mfaEnabled}
              onChange={handleToggleMFA}
              size="lg"
            />
          </Group>
          {!mfaEnabled && (
            <Alert icon={<IconAlertTriangle size={16} />} color="orange">
              建議啟用 MFA 以保護您的帳號。即使密碼被盜,攻擊者也無法登入。
            </Alert>
          )}
        </Paper>
        {/* 信任的裝置 */}
        <Paper withBorder p="lg">
          <Group position="apart" mb="md">
            <div>
              <Text weight={500}>信任的裝置</Text>
              <Text size="sm" color="dimmed">
                管理您已登入的裝置
              </Text>
            </div>
            <Badge>{devices.length} 個裝置</Badge>
          </Group>
          <Stack spacing="md">
            {devices.map((device) => (
              <Card key={device.id} withBorder padding="md">
                <Group position="apart">
                  <div style={{ flex: 1 }}>
                    <Group spacing="xs" mb={4}>
                      <IconDevices size={18} />
                      <Text weight={500}>{device.browser} - {device.os}</Text>
                      {device.isCurrent && (
                        <Badge color="blue" size="sm">目前裝置</Badge>
                      )}
                      {device.trusted && (
                        <Badge color="green" size="sm">信任</Badge>
                      )}
                    </Group>
                    <Group spacing="lg">
                      <Group spacing={4}>
                        <IconMapPin size={14} />
                        <Text size="xs" color="dimmed">
                          {device.location || device.ipAddress}
                        </Text>
                      </Group>
                      <Group spacing={4}>
                        <IconClock size={14} />
                        <Text size="xs" color="dimmed">
                          {formatDistanceToNow(new Date(device.lastAccessAt), {
                            addSuffix: true,
                            locale: zhTW,
                          })}
                        </Text>
                      </Group>
                    </Group>
                  </div>
                  {!device.isCurrent && (
                    <ActionIcon
                      color="red"
                      variant="subtle"
                      onClick={() => {
                        setSelectedDevice(device);
                        setRevokeModalOpen(true);
                      }}
                    >
                      <IconTrash size={18} />
                    </ActionIcon>
                  )}
                </Group>
              </Card>
            ))}
          </Stack>
        </Paper>
        {/* 登入歷史 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            登入歷史
          </Text>
          <Timeline active={-1} bulletSize={24} lineWidth={2}>
            {loginHistory.slice(0, 10).map((log) => (
              <Timeline.Item
                key={log.id}
                bullet={log.success ? <IconCheck size={12} /> : <IconX size={12} />}
                title={
                  <Group spacing="xs">
                    <Text size="sm">
                      {log.success ? '成功登入' : '登入失敗'}
                    </Text>
                    {log.mfaUsed && (
                      <Badge size="xs" color="blue">MFA</Badge>
                    )}
                  </Group>
                }
              >
                <Text size="xs" color="dimmed">
                  {log.device}
                </Text>
                <Text size="xs" color="dimmed">
                  {log.location || log.ipAddress} · {' '}
                  {formatDistanceToNow(new Date(log.timestamp), {
                    addSuffix: true,
                    locale: zhTW,
                  })}
                </Text>
              </Timeline.Item>
            ))}
          </Timeline>
        </Paper>
        {/* 其他安全選項 */}
        <Paper withBorder p="lg">
          <Text weight={500} mb="md">
            其他安全選項
          </Text>
          <Stack spacing="md">
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>變更密碼</Text>
                <Text size="xs" color="dimmed">定期更新您的密碼</Text>
              </div>
              <Button variant="light" size="xs">
                變更
              </Button>
            </Group>
            <Divider />
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>登出所有裝置</Text>
                <Text size="xs" color="dimmed">撤銷所有裝置的存取權限</Text>
              </div>
              <Button variant="light" color="red" size="xs">
                登出全部
              </Button>
            </Group>
            <Divider />
            <Group position="apart">
              <div>
                <Text size="sm" weight={500}>下載個人資料</Text>
                <Text size="xs" color="dimmed">匯出您的所有資料</Text>
              </div>
              <Button variant="light" size="xs">
                下載
              </Button>
            </Group>
          </Stack>
        </Paper>
      </Stack>
      {/* 撤銷裝置確認 Modal */}
      <Modal
        opened={revokeModalOpen}
        onClose={() => setRevokeModalOpen(false)}
        title="撤銷裝置存取"
      >
        <Stack spacing="md">
          <Alert icon={<IconAlertTriangle size={16} />} color="orange">
            此操作將登出該裝置,您需要重新登入才能繼續使用。
          </Alert>
          {selectedDevice && (
            <div>
              <Text size="sm" weight={500}>裝置資訊:</Text>
              <Text size="sm" color="dimmed">
                {selectedDevice.browser} - {selectedDevice.os}
              </Text>
              <Text size="sm" color="dimmed">
                {selectedDevice.location || selectedDevice.ipAddress}
              </Text>
            </div>
          )}
          <Group position="right">
            <Button variant="subtle" onClick={() => setRevokeModalOpen(false)}>
              取消
            </Button>
            <Button
              color="red"
              onClick={() => selectedDevice && handleRevokeDevice(selectedDevice.id)}
            >
              確認撤銷
            </Button>
          </Group>
        </Stack>
      </Modal>
    </Container>
  );
}
// src/utils/device-fingerprint.ts
import FingerprintJS from '@fingerprintjs/fingerprintjs';
/**
 * 裝置指紋服務
 *
 * 用途:
 * - 識別用戶裝置
 * - 檢測異常登入
 * - 實現「記住此裝置」功能
 */
export class DeviceFingerprintService {
  private static instance: DeviceFingerprintService;
  private fpPromise: Promise<any>;
  private constructor() {
    // 初始化 FingerprintJS
    this.fpPromise = FingerprintJS.load();
  }
  static getInstance(): DeviceFingerprintService {
    if (!DeviceFingerprintService.instance) {
      DeviceFingerprintService.instance = new DeviceFingerprintService();
    }
    return DeviceFingerprintService.instance;
  }
  /**
   * 取得裝置指紋
   */
  async getFingerprint(): Promise<string> {
    const fp = await this.fpPromise;
    const result = await fp.get();
    return result.visitorId;
  }
  /**
   * 取得裝置資訊
   */
  getDeviceInfo(): {
    browser: string;
    os: string;
    device: string;
    screenResolution: string;
    timezone: string;
    language: string;
  } {
    const userAgent = navigator.userAgent;
    const platform = navigator.platform;
    // 簡化的瀏覽器檢測
    let browser = 'Unknown';
    if (userAgent.includes('Firefox')) browser = 'Firefox';
    else if (userAgent.includes('Chrome')) browser = 'Chrome';
    else if (userAgent.includes('Safari')) browser = 'Safari';
    else if (userAgent.includes('Edge')) browser = 'Edge';
    // 簡化的 OS 檢測
    let os = 'Unknown';
    if (platform.includes('Win')) os = 'Windows';
    else if (platform.includes('Mac')) os = 'macOS';
    else if (platform.includes('Linux')) os = 'Linux';
    else if (/Android/.test(userAgent)) os = 'Android';
    else if (/iPhone|iPad/.test(userAgent)) os = 'iOS';
    // 裝置類型
    const isMobile = /Mobi|Android/i.test(userAgent);
    const isTablet = /Tablet|iPad/i.test(userAgent);
    let device = 'Desktop';
    if (isTablet) device = 'Tablet';
    else if (isMobile) device = 'Mobile';
    return {
      browser,
      os,
      device,
      screenResolution: `${window.screen.width}x${window.screen.height}`,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      language: navigator.language,
    };
  }
  /**
   * 檢查是否為信任的裝置
   */
  async isTrustedDevice(): Promise<boolean> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );
    return trustedDevices.includes(fingerprint);
  }
  /**
   * 將裝置標記為信任
   */
  async trustDevice(): Promise<void> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );
    if (!trustedDevices.includes(fingerprint)) {
      trustedDevices.push(fingerprint);
      localStorage.setItem('trustedDevices', JSON.stringify(trustedDevices));
    }
  }
  /**
   * 移除裝置信任
   */
  async untrustDevice(): Promise<void> {
    const fingerprint = await this.getFingerprint();
    const trustedDevices = JSON.parse(
      localStorage.getItem('trustedDevices') || '[]'
    );
    const filtered = trustedDevices.filter((id: string) => id !== fingerprint);
    localStorage.setItem('trustedDevices', JSON.stringify(filtered));
  }
}
// 使用範例
export const deviceFingerprint = DeviceFingerprintService.getInstance();
我們今天完成了 Kyo System 的企業級帳號安全系統:
TOTP vs SMS OTP:
裝置信任機制:
安全分數計算:
備份碼最佳實踐: