iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Modern Web

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

Day 25: 30天打造SaaS產品前端篇-用戶認證系統前端實作

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 24 的 Landing Page 建置,我們已經有了吸引使用者的門面。今天我們要實作用戶認證系統的前端部分,包括登入/註冊表單、Token 管理、Protected Routes、以及完整的認證流程。這是從訪客轉換為付費用戶的關鍵部分。

認證系統架構概覽

/**
 * 前端認證系統架構
 *
 * ┌─────────────────────────────────────────────┐
 * │           User Authentication Flow          │
 * └─────────────────────────────────────────────┘
 *
 * 1. 訪客訪問 Landing Page
 *    ↓
 * 2. 點擊「免費開始使用」
 *    ↓
 * 3. 進入註冊流程
 *    ├─ 信箱驗證
 *    ├─ 密碼設定
 *    └─ 帳號啟用
 *    ↓
 * 4. 登入系統
 *    ├─ 取得 Access Token
 *    └─ 取得 Refresh Token
 *    ↓
 * 5. 存儲 Token (httpOnly cookie + localStorage)
 *    ↓
 * 6. 自動附加 Token 到 API 請求
 *    ↓
 * 7. Token 過期自動刷新
 *    ↓
 * 8. 登出清除 Token
 *
 * 安全考量:
 * ✅ Access Token: 短期 (15分鐘),存 localStorage
 * ✅ Refresh Token: 長期 (7天),存 httpOnly cookie
 * ✅ CSRF Protection
 * ✅ XSS Prevention
 * ✅ 密碼強度驗證
 * ✅ Rate Limiting
 */

認證上下文 (Auth Context)

// src/contexts/AuthContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';

export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  role: 'admin' | 'user' | 'viewer';
  tenantId: string;
  emailVerified: boolean;
  createdAt: string;
}

export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

interface AuthContextValue {
  // 狀態
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  error: string | null;

  // 方法
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  register: (data: RegisterData) => Promise<void>;
  updateProfile: (data: Partial<User>) => Promise<void>;
  refreshAccessToken: () => Promise<string>;
  clearError: () => void;
}

interface RegisterData {
  email: string;
  password: string;
  name: string;
  tenantName?: string;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const navigate = useNavigate();

  const isAuthenticated = !!user;

  /**
   * 從 localStorage 取得 Access Token
   */
  const getAccessToken = useCallback((): string | null => {
    return localStorage.getItem('accessToken');
  }, []);

  /**
   * 儲存 Access Token
   */
  const setAccessToken = useCallback((token: string) => {
    localStorage.setItem('accessToken', token);
  }, []);

  /**
   * 清除 Access Token
   */
  const clearAccessToken = useCallback(() => {
    localStorage.removeItem('accessToken');
  }, []);

  /**
   * API 請求封裝
   */
  const apiRequest = useCallback(
    async <T,>(
      endpoint: string,
      options: RequestInit = {}
    ): Promise<T> => {
      const token = getAccessToken();

      const headers: HeadersInit = {
        'Content-Type': 'application/json',
        ...options.headers,
      };

      if (token) {
        headers['Authorization'] = `Bearer ${token}`;
      }

      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        ...options,
        headers,
        credentials: 'include', // 包含 cookies (Refresh Token)
      });

      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}));
        throw new Error(errorData.message || 'Request failed');
      }

      return response.json();
    },
    [getAccessToken]
  );

  /**
   * 刷新 Access Token
   */
  const refreshAccessToken = useCallback(async (): Promise<string> => {
    try {
      const data = await apiRequest<{ accessToken: string }>(
        '/api/auth/refresh',
        {
          method: 'POST',
        }
      );

      setAccessToken(data.accessToken);
      return data.accessToken;
    } catch (err) {
      // Refresh Token 也過期了,需要重新登入
      clearAccessToken();
      setUser(null);
      throw err;
    }
  }, [apiRequest, setAccessToken, clearAccessToken]);

  /**
   * 取得當前用戶資訊
   */
  const fetchCurrentUser = useCallback(async () => {
    try {
      const data = await apiRequest<{ user: User }>('/api/auth/me');
      setUser(data.user);
    } catch (err) {
      // Access Token 無效,嘗試刷新
      try {
        await refreshAccessToken();
        const data = await apiRequest<{ user: User }>('/api/auth/me');
        setUser(data.user);
      } catch (refreshErr) {
        // 刷新失敗,清除狀態
        clearAccessToken();
        setUser(null);
      }
    } finally {
      setIsLoading(false);
    }
  }, [apiRequest, refreshAccessToken, clearAccessToken]);

  /**
   * 登入
   */
  const login = useCallback(
    async (email: string, password: string) => {
      try {
        setIsLoading(true);
        setError(null);

        const data = await apiRequest<{
          user: User;
          accessToken: string;
        }>('/api/auth/login', {
          method: 'POST',
          body: JSON.stringify({ email, password }),
        });

        setAccessToken(data.accessToken);
        setUser(data.user);

        // 重定向到 Dashboard
        navigate('/dashboard');
      } catch (err: any) {
        setError(err.message || '登入失敗');
        throw err;
      } finally {
        setIsLoading(false);
      }
    },
    [apiRequest, setAccessToken, navigate]
  );

  /**
   * 註冊
   */
  const register = useCallback(
    async (data: RegisterData) => {
      try {
        setIsLoading(true);
        setError(null);

        const response = await apiRequest<{
          user: User;
          accessToken: string;
          message: string;
        }>('/api/auth/register', {
          method: 'POST',
          body: JSON.stringify(data),
        });

        setAccessToken(response.accessToken);
        setUser(response.user);

        // 重定向到 Dashboard
        navigate('/dashboard');
      } catch (err: any) {
        setError(err.message || '註冊失敗');
        throw err;
      } finally {
        setIsLoading(false);
      }
    },
    [apiRequest, setAccessToken, navigate]
  );

  /**
   * 登出
   */
  const logout = useCallback(async () => {
    try {
      setIsLoading(true);

      // 呼叫後端登出 API (清除 Refresh Token)
      await apiRequest('/api/auth/logout', {
        method: 'POST',
      });
    } catch (err) {
      console.error('Logout API failed:', err);
    } finally {
      // 清除前端狀態
      clearAccessToken();
      setUser(null);
      setIsLoading(false);

      // 重定向到首頁
      navigate('/');
    }
  }, [apiRequest, clearAccessToken, navigate]);

  /**
   * 更新個人資料
   */
  const updateProfile = useCallback(
    async (updates: Partial<User>) => {
      try {
        setIsLoading(true);
        setError(null);

        const data = await apiRequest<{ user: User }>('/api/auth/profile', {
          method: 'PATCH',
          body: JSON.stringify(updates),
        });

        setUser(data.user);
      } catch (err: any) {
        setError(err.message || '更新失敗');
        throw err;
      } finally {
        setIsLoading(false);
      }
    },
    [apiRequest]
  );

  /**
   * 清除錯誤
   */
  const clearError = useCallback(() => {
    setError(null);
  }, []);

  /**
   * 初始化:檢查是否已登入
   */
  useEffect(() => {
    const token = getAccessToken();
    if (token) {
      fetchCurrentUser();
    } else {
      setIsLoading(false);
    }
  }, [getAccessToken, fetchCurrentUser]);

  /**
   * 設定 Token 自動刷新
   */
  useEffect(() => {
    if (!isAuthenticated) return;

    // 每 14 分鐘刷新一次 (Access Token 15 分鐘過期)
    const intervalId = setInterval(
      async () => {
        try {
          await refreshAccessToken();
        } catch (err) {
          console.error('Auto refresh failed:', err);
        }
      },
      14 * 60 * 1000
    );

    return () => clearInterval(intervalId);
  }, [isAuthenticated, refreshAccessToken]);

  const value: AuthContextValue = {
    user,
    isAuthenticated,
    isLoading,
    error,
    login,
    logout,
    register,
    updateProfile,
    refreshAccessToken,
    clearError,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

登入表單元件

// src/pages/Auth/LoginPage.tsx
import { useState } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  TextInput,
  PasswordInput,
  Button,
  Group,
  Anchor,
  Stack,
  Divider,
  Alert,
} from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
import { IconAlertCircle, IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

const loginSchema = z.object({
  email: z.string().email('請輸入有效的電子郵件'),
  password: z.string().min(8, '密碼至少需要 8 個字元'),
});

type LoginFormValues = z.infer<typeof loginSchema>;

export function LoginPage() {
  const { login, error, clearError } = useAuth();
  const [isLoading, setIsLoading] = useState(false);
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();

  const redirectTo = searchParams.get('redirect') || '/dashboard';

  const form = useForm<LoginFormValues>({
    validate: zodResolver(loginSchema),
    initialValues: {
      email: '',
      password: '',
    },
  });

  const handleSubmit = async (values: LoginFormValues) => {
    try {
      setIsLoading(true);
      clearError();

      await login(values.email, values.password);

      // 登入成功會自動重定向到 Dashboard
    } catch (err) {
      console.error('Login failed:', err);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Container size={420} my={80}>
      <Paper radius="md" p="xl" withBorder>
        <Title order={2} ta="center" mb="md">
          歡迎回來
        </Title>

        <Text c="dimmed" size="sm" ta="center" mb="xl">
          還沒有帳號?{' '}
          <Anchor component={Link} to="/register" size="sm">
            建立帳號
          </Anchor>
        </Text>

        {error && (
          <Alert
            icon={<IconAlertCircle size={16} />}
            title="登入失敗"
            color="red"
            mb="md"
            onClose={clearError}
            withCloseButton
          >
            {error}
          </Alert>
        )}

        <form onSubmit={form.onSubmit(handleSubmit)}>
          <Stack spacing="md">
            <TextInput
              label="電子郵件"
              placeholder="your@email.com"
              required
              {...form.getInputProps('email')}
            />

            <PasswordInput
              label="密碼"
              placeholder="您的密碼"
              required
              {...form.getInputProps('password')}
            />

            <Group position="apart">
              <Anchor component={Link} to="/forgot-password" size="sm">
                忘記密碼?
              </Anchor>
            </Group>

            <Button type="submit" fullWidth loading={isLoading}>
              登入
            </Button>
          </Stack>
        </form>

        <Divider label="或使用以下方式登入" labelPosition="center" my="lg" />

        <Stack spacing="sm">
          <Button
            variant="default"
            leftIcon={<IconBrandGoogle size={18} />}
            onClick={() => {
              window.location.href = `${import.meta.env.VITE_API_URL}/api/auth/google`;
            }}
          >
            使用 Google 登入
          </Button>

          <Button
            variant="default"
            leftIcon={<IconBrandGithub size={18} />}
            onClick={() => {
              window.location.href = `${import.meta.env.VITE_API_URL}/api/auth/github`;
            }}
          >
            使用 GitHub 登入
          </Button>
        </Stack>
      </Paper>
    </Container>
  );
}

註冊表單元件

// src/pages/Auth/RegisterPage.tsx
import { useState } from 'react';
import {
  Container,
  Paper,
  Title,
  Text,
  TextInput,
  PasswordInput,
  Button,
  Anchor,
  Stack,
  Alert,
  Progress,
  Group,
  Checkbox,
} from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
import { IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

const registerSchema = z
  .object({
    name: z.string().min(2, '名稱至少需要 2 個字元'),
    email: z.string().email('請輸入有效的電子郵件'),
    password: z
      .string()
      .min(8, '密碼至少需要 8 個字元')
      .regex(/[A-Z]/, '密碼必須包含至少一個大寫字母')
      .regex(/[a-z]/, '密碼必須包含至少一個小寫字母')
      .regex(/[0-9]/, '密碼必須包含至少一個數字')
      .regex(/[^A-Za-z0-9]/, '密碼必須包含至少一個特殊字元'),
    confirmPassword: z.string(),
    tenantName: z.string().optional(),
    acceptTerms: z.boolean().refine((val) => val === true, {
      message: '您必須同意服務條款',
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '密碼不一致',
    path: ['confirmPassword'],
  });

type RegisterFormValues = z.infer<typeof registerSchema>;

export function RegisterPage() {
  const { register, error, clearError } = useAuth();
  const [isLoading, setIsLoading] = useState(false);

  const form = useForm<RegisterFormValues>({
    validate: zodResolver(registerSchema),
    initialValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
      tenantName: '',
      acceptTerms: false,
    },
  });

  const handleSubmit = async (values: RegisterFormValues) => {
    try {
      setIsLoading(true);
      clearError();

      await register({
        name: values.name,
        email: values.email,
        password: values.password,
        tenantName: values.tenantName,
      });

      // 註冊成功會自動重定向到 Dashboard
    } catch (err) {
      console.error('Register failed:', err);
    } finally {
      setIsLoading(false);
    }
  };

  // 密碼強度計算
  const getPasswordStrength = (password: string): number => {
    let strength = 0;
    if (password.length >= 8) strength += 25;
    if (/[A-Z]/.test(password)) strength += 25;
    if (/[a-z]/.test(password)) strength += 25;
    if (/[0-9]/.test(password)) strength += 12.5;
    if (/[^A-Za-z0-9]/.test(password)) strength += 12.5;
    return strength;
  };

  const passwordStrength = getPasswordStrength(form.values.password);

  const getPasswordStrengthColor = (strength: number): string => {
    if (strength < 40) return 'red';
    if (strength < 70) return 'yellow';
    return 'green';
  };

  return (
    <Container size={480} my={80}>
      <Paper radius="md" p="xl" withBorder>
        <Title order={2} ta="center" mb="md">
          建立帳號
        </Title>

        <Text c="dimmed" size="sm" ta="center" mb="xl">
          已經有帳號了?{' '}
          <Anchor component={Link} to="/login" size="sm">
            登入
          </Anchor>
        </Text>

        {error && (
          <Alert
            icon={<IconAlertCircle size={16} />}
            title="註冊失敗"
            color="red"
            mb="md"
            onClose={clearError}
            withCloseButton
          >
            {error}
          </Alert>
        )}

        <form onSubmit={form.onSubmit(handleSubmit)}>
          <Stack spacing="md">
            <TextInput
              label="姓名"
              placeholder="您的名字"
              required
              {...form.getInputProps('name')}
            />

            <TextInput
              label="電子郵件"
              placeholder="your@email.com"
              required
              {...form.getInputProps('email')}
            />

            <TextInput
              label="組織名稱(選填)"
              placeholder="您的公司或團隊名稱"
              {...form.getInputProps('tenantName')}
            />

            <div>
              <PasswordInput
                label="密碼"
                placeholder="設定您的密碼"
                required
                {...form.getInputProps('password')}
              />

              {form.values.password && (
                <div style={{ marginTop: 8 }}>
                  <Progress
                    value={passwordStrength}
                    color={getPasswordStrengthColor(passwordStrength)}
                    size="xs"
                    mb={4}
                  />
                  <Text size="xs" c="dimmed">
                    密碼強度:
                    {passwordStrength < 40 && ' 弱'}
                    {passwordStrength >= 40 && passwordStrength < 70 && ' 中等'}
                    {passwordStrength >= 70 && ' 強'}
                  </Text>
                </div>
              )}
            </div>

            <PasswordInput
              label="確認密碼"
              placeholder="再次輸入密碼"
              required
              {...form.getInputProps('confirmPassword')}
            />

            {/* 密碼要求提示 */}
            <Stack spacing={4}>
              <Text size="xs" weight={500}>
                密碼要求:
              </Text>
              <PasswordRequirement
                met={form.values.password.length >= 8}
                label="至少 8 個字元"
              />
              <PasswordRequirement
                met={/[A-Z]/.test(form.values.password)}
                label="至少一個大寫字母"
              />
              <PasswordRequirement
                met={/[a-z]/.test(form.values.password)}
                label="至少一個小寫字母"
              />
              <PasswordRequirement
                met={/[0-9]/.test(form.values.password)}
                label="至少一個數字"
              />
              <PasswordRequirement
                met={/[^A-Za-z0-9]/.test(form.values.password)}
                label="至少一個特殊字元"
              />
            </Stack>

            <Checkbox
              label={
                <>
                  我同意{' '}
                  <Anchor component={Link} to="/terms" target="_blank">
                    服務條款
                  </Anchor>{' '}
                  和{' '}
                  <Anchor component={Link} to="/privacy" target="_blank">
                    隱私政策
                  </Anchor>
                </>
              }
              {...form.getInputProps('acceptTerms', { type: 'checkbox' })}
            />

            <Button type="submit" fullWidth loading={isLoading}>
              建立帳號
            </Button>
          </Stack>
        </form>
      </Paper>
    </Container>
  );
}

function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
  return (
    <Group spacing={4} c={met ? 'green' : 'dimmed'}>
      {met ? <IconCheck size={14} /> : <IconX size={14} />}
      <Text size="xs">{label}</Text>
    </Group>
  );
}

Protected Route 實作

// src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Center, Loader } from '@mantine/core';

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRole?: 'admin' | 'user' | 'viewer';
}

export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
  const { isAuthenticated, isLoading, user } = useAuth();
  const location = useLocation();

  // 載入中
  if (isLoading) {
    return (
      <Center style={{ height: '100vh' }}>
        <Loader size="lg" />
      </Center>
    );
  }

  // 未登入,重定向到登入頁
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // 檢查角色權限
  if (requiredRole && user) {
    const roleHierarchy = { viewer: 0, user: 1, admin: 2 };
    const userRoleLevel = roleHierarchy[user.role];
    const requiredRoleLevel = roleHierarchy[requiredRole];

    if (userRoleLevel < requiredRoleLevel) {
      return <Navigate to="/unauthorized" replace />;
    }
  }

  return <>{children}</>;
}

API Interceptor (Axios 版本)

// src/lib/api-client.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

/**
 * 建立 Axios 實例
 */
const apiClient: AxiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: 10000,
  withCredentials: true, // 包含 cookies
  headers: {
    'Content-Type': 'application/json',
  },
});

/**
 * Request Interceptor
 * 自動附加 Access Token
 */
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const accessToken = localStorage.getItem('accessToken');

    if (accessToken && config.headers) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }

    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

/**
 * Response Interceptor
 * 自動處理 Token 過期
 */
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (value?: any) => void;
  reject: (reason?: any) => void;
}> = [];

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
};

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

    // 如果是 401 且不是 refresh 端點,嘗試刷新 Token
    if (
      error.response?.status === 401 &&
      !originalRequest._retry &&
      originalRequest.url !== '/api/auth/refresh'
    ) {
      if (isRefreshing) {
        // 如果正在刷新,將請求加入隊列
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return apiClient(originalRequest);
          })
          .catch((err) => {
            return Promise.reject(err);
          });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // 呼叫 refresh 端點
        const response = await apiClient.post('/api/auth/refresh');
        const { accessToken } = response.data;

        // 更新 localStorage
        localStorage.setItem('accessToken', accessToken);

        // 處理隊列中的請求
        processQueue(null, accessToken);

        // 重試原始請求
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        // Refresh 失敗,清除 Token 並重定向
        processQueue(refreshError, null);
        localStorage.removeItem('accessToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

Session 管理與活動偵測

// src/hooks/useIdleTimeout.ts
import { useEffect, useRef, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { notifications } from '@mantine/notifications';

interface UseIdleTimeoutOptions {
  idleTime?: number; // 閒置時間(毫秒)
  warningTime?: number; // 警告時間(毫秒)
  onIdle?: () => void;
  onActive?: () => void;
}

/**
 * 偵測用戶閒置並自動登出
 */
export function useIdleTimeout(options: UseIdleTimeoutOptions = {}) {
  const {
    idleTime = 30 * 60 * 1000, // 預設 30 分鐘
    warningTime = 5 * 60 * 1000, // 預設 5 分鐘前警告
    onIdle,
    onActive,
  } = options;

  const { logout, isAuthenticated } = useAuth();
  const timeoutId = useRef<number>();
  const warningId = useRef<number>();
  const lastActivityTime = useRef<number>(Date.now());

  /**
   * 重置計時器
   */
  const resetTimer = useCallback(() => {
    lastActivityTime.current = Date.now();

    // 清除舊的計時器
    if (timeoutId.current) {
      clearTimeout(timeoutId.current);
    }
    if (warningId.current) {
      clearTimeout(warningId.current);
    }

    // 設定警告計時器
    warningId.current = window.setTimeout(() => {
      notifications.show({
        title: '即將登出',
        message: '由於您已閒置一段時間,系統將在 5 分鐘後自動登出',
        color: 'yellow',
        autoClose: 10000,
      });
    }, idleTime - warningTime);

    // 設定登出計時器
    timeoutId.current = window.setTimeout(() => {
      notifications.show({
        title: '已自動登出',
        message: '由於長時間未活動,您已被自動登出',
        color: 'red',
      });

      logout();
      onIdle?.();
    }, idleTime);

    onActive?.();
  }, [idleTime, warningTime, logout, onIdle, onActive]);

  useEffect(() => {
    if (!isAuthenticated) return;

    // 監聽的事件
    const events = [
      'mousedown',
      'mousemove',
      'keypress',
      'scroll',
      'touchstart',
      'click',
    ];

    // 節流:避免過於頻繁地重置計時器
    let isThrottled = false;
    const throttleTime = 1000; // 1 秒

    const handleActivity = () => {
      if (isThrottled) return;

      isThrottled = true;
      resetTimer();

      setTimeout(() => {
        isThrottled = false;
      }, throttleTime);
    };

    // 綁定事件
    events.forEach((event) => {
      window.addEventListener(event, handleActivity);
    });

    // 初始化計時器
    resetTimer();

    // 清理
    return () => {
      events.forEach((event) => {
        window.removeEventListener(event, handleActivity);
      });

      if (timeoutId.current) {
        clearTimeout(timeoutId.current);
      }
      if (warningId.current) {
        clearTimeout(warningId.current);
      }
    };
  }, [isAuthenticated, resetTimer]);

  return {
    resetTimer,
    lastActivityTime: lastActivityTime.current,
  };
}

社交登入整合

// src/utils/social-auth.ts
/**
 * 社交登入處理
 *
 * OAuth 2.0 流程:
 * 1. 前端重定向到 OAuth Provider
 * 2. 用戶授權
 * 3. Provider 重定向回 callback URL
 * 4. 後端交換 code 取得 access token
 * 5. 取得用戶資訊並建立/登入帳號
 * 6. 返回前端 JWT token
 */

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';

/**
 * Google 登入
 */
export function loginWithGoogle() {
  const callbackUrl = encodeURIComponent(`${window.location.origin}/auth/callback/google`);
  window.location.href = `${API_BASE_URL}/api/auth/google?redirect=${callbackUrl}`;
}

/**
 * GitHub 登入
 */
export function loginWithGithub() {
  const callbackUrl = encodeURIComponent(`${window.location.origin}/auth/callback/github`);
  window.location.href = `${API_BASE_URL}/api/auth/github?redirect=${callbackUrl}`;
}

/**
 * 處理 OAuth Callback
 */
export function handleOAuthCallback(
  provider: 'google' | 'github',
  searchParams: URLSearchParams
): {
  success: boolean;
  accessToken?: string;
  error?: string;
} {
  const accessToken = searchParams.get('access_token');
  const error = searchParams.get('error');

  if (error) {
    return { success: false, error };
  }

  if (accessToken) {
    // 儲存 Token
    localStorage.setItem('accessToken', accessToken);
    return { success: true, accessToken };
  }

  return { success: false, error: 'No token received' };
}
// src/pages/Auth/OAuthCallbackPage.tsx
import { useEffect } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { Center, Loader, Text, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { handleOAuthCallback } from '../../utils/social-auth';
import { useAuth } from '../../contexts/AuthContext';

export function OAuthCallbackPage() {
  const { provider } = useParams<{ provider: 'google' | 'github' }>();
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const { refreshAccessToken } = useAuth();

  useEffect(() => {
    if (!provider) {
      navigate('/login');
      return;
    }

    const result = handleOAuthCallback(provider, searchParams);

    if (result.success && result.accessToken) {
      // 取得用戶資訊
      refreshAccessToken()
        .then(() => {
          notifications.show({
            title: '登入成功',
            message: `歡迎使用 ${provider === 'google' ? 'Google' : 'GitHub'} 登入`,
            color: 'green',
          });

          navigate('/dashboard');
        })
        .catch((err) => {
          console.error('Failed to fetch user:', err);
          navigate('/login');
        });
    } else {
      notifications.show({
        title: '登入失敗',
        message: result.error || '發生未知錯誤',
        color: 'red',
      });

      navigate('/login');
    }
  }, [provider, searchParams, navigate, refreshAccessToken]);

  return (
    <Center style={{ height: '100vh' }}>
      <Stack align="center">
        <Loader size="lg" />
        <Text>正在完成登入...</Text>
      </Stack>
    </Center>
  );
}

今日總結

我們今天完成了用戶認證系統的前端實作:

核心成就

  1. Auth Context: 集中式認證狀態管理
  2. 登入/註冊: 完整的表單驗證與錯誤處理
  3. Token 管理: Access Token + Refresh Token 雙 Token 機制
  4. Protected Routes: 路由保護與角色權限控制
  5. API Interceptor: 自動附加 Token 與自動刷新
  6. 閒置偵測: 自動登出機制
  7. 社交登入: OAuth 2.0 整合

技術深度分析

Token 存儲策略:

  • Access Token: localStorage(短期,15 分鐘)
  • Refresh Token: httpOnly cookie(長期,7 天)
  • 💡 為什麼分開?防止 XSS 竊取長期憑證

Token 刷新機制:

  • 被動刷新: API 返回 401 時觸發
  • 主動刷新: 每 14 分鐘自動刷新
  • 隊列處理: 多個同時失敗的請求只刷新一次
  • 💡 避免 Token 過期造成的 Race Condition

密碼強度驗證:

  • 至少 8 個字元
  • 大寫 + 小寫 + 數字 + 特殊字元
  • 即時視覺化回饋
  • 💡 前後端雙重驗證

閒置超時策略:

  • 閒置 25 分鐘:警告
  • 閒置 30 分鐘:自動登出
  • 節流事件監聽(避免過度觸發)
  • 💡 平衡安全性與用戶體驗

前端認證檢查清單

  • ✅ Auth Context 狀態管理
  • ✅ 登入/註冊表單
  • ✅ 密碼強度驗證
  • ✅ Token 自動刷新
  • ✅ Protected Routes
  • ✅ 角色權限控制
  • ✅ API Interceptor
  • ✅ 閒置超時偵測
  • ✅ 社交登入整合
  • ✅ 錯誤處理與提示

上一篇
Day 24: 30天打造SaaS產品前端篇-Landing Page 設計與實作
系列文
30 天製作工作室 SaaS 產品 (前端篇)25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言