iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0

承接前一天的內容,今天要來講前端怎麼實作登入機制

講到登入機制,當初首先想到的問題是:

要不要實作完整的帳號/密碼登入?

不要,因為真的太麻煩了。完整的帳號/密碼登入機制至少需要:

  • 註冊
  • 登入
  • 修改/忘記密碼

延伸還會需要:

  • 密碼雜湊與加密
  • 註冊/登入頁面
  • 忘記/修改密碼頁面

而且實際使用上能用 Google 登入就用 Google 登入就好

決定用 Google 登入之後,第二個問題是:

登入之後,要怎麼管理登入狀態?

最簡單的做法,是每次呼叫 API 都附上 Google 的 Token,讓後端去驗簽章。

但這樣後端每次都要跟 Google 驗證,效能與維護上都不划算。

而且實際經驗上我**更熟悉用 JWT:自己發 Token、自己驗,**清楚又好管。

所以最後決定採用 Google OAuth + JWT 的組合架構。

接下來就一步步實作這套登入系統,讓使用者能安全地存取 MyMomentum。

Google登入實作

  1. 首先前端專案需要先安裝Google OAuth相關套件:
npm install @react-oauth/google jwt-decode
  1. 設定 GoogleOAuthProvider

我們需要在index.tsx 包裝GoogleOAuthProvider,這樣所有子組件都可以使用Google登入功能。

// src/index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { AuthProvider } from "./contexts/AuthContext";
import App from "./App";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

root.render(
  <React.StrictMode>
    <BrowserRouter>
      <GoogleOAuthProvider clientId="861849830529-44mlejpcr9jjhh19qbvib8vjfbo42gea.apps.googleusercontent.com">
        <AuthProvider>
          <App />
        </AuthProvider>
      </GoogleOAuthProvider>
    </BrowserRouter>
  </React.StrictMode>
);

這裡做什麼:在應用最上層設定 Google OAuth Provider,並包裝我們的認證 Context。
這段程式碼的重點:Provider 的順序很重要,GoogleOAuthProvider 要在最外層,AuthProvider 在內層,這樣才能正確傳遞 Google 登入功能到我們的認證系統。

GoogleOAuthProviderclientId

GoogleOAuthProvider 是 Google 官方套件提供的 Context,它會把 Google 的登入 SDK 注入到 React 應用中。

要使用它,必須傳入一個 clientId,這是你在 Google Cloud Console 裡面建立 OAuth 憑證時拿到的。

https://ithelp.ithome.com.tw/upload/images/20250921/20160279ifHTZQT8y3.png

這個 ID 綁定了應用程式的來源(例如本機 http://localhost:3000 或正式域名),只有合法的來源才能使用登入。


登入元件:GoogleLogin 按鈕與憑證處理

在登入前的Intro頁面,讓用戶點擊 Google 按鈕後取得憑證,然後發送到後端驗證。

導入方式:

// IntroPage.tsx
import { GoogleLogin } from "@react-oauth/google";
import { authenticateWithGoogle } from "../services/auth";
import { useAuth } from "../contexts/AuthContext";

const IntroPage = () => {
  const { setAccessToken } = useAuth();

  // Google 登入後的憑證處理
  const handleGoogleCredential = async (credential?: string) => {
    if (!credential) return;
    const { accessToken } = await authenticateWithGoogle(credential);
    setAccessToken(accessToken);
  };

  return (
    <GoogleLogin
      onSuccess={(res) => handleGoogleCredential(res.credential)}
      onError={() => console.error("Google 登入失敗")}
    />
  );
};

export default IntroPage;

這段程式碼的重點

  1. GoogleLogin 元件會呼叫 Google OAuth 流程,登入成功後回傳 credential(Google ID Token)。
  2. handleGoogleCredential:把 Google ID Token 傳給後端,後端驗證成功後會回傳我們自己的 JWT
  3. setAccessToken:把 JWT 存起來(例如放進 Context 和 localStorage),之後呼叫 API 時會用到。

發送 Google ID Token 到後端

補充一下,登入按鈕拿到 Google ID Token 之後,需要呼叫後端 API 來完成驗證。

建立一個 auth.ts

// src/services/auth.ts
const API_BASE = "http://localhost:8080";

export type AuthResponse = {
  accessToken: string; // 後端回傳的 JWT
};

export const authenticateWithGoogle = async (
  idToken: string
): Promise<AuthResponse> => {
  const response = await fetch(`${API_BASE}/auth/google`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ idToken }),
  });

  if (!response.ok) throw new Error("驗證失敗");

  return response.json();
};

這段程式碼的重點

  • 把 Google ID Token 傳給後端 /auth/google API。
  • 後端會驗證這個 Token 的真實性,如果通過,就回傳一個屬於我們系統的 JWT
  • 前端把這個 JWT 存起來,後續呼叫 API 時就可以使用。

AuthContext:集中管理 JWT

然後我們需要一個地方統一管理 JWT,這邊要:

  • 儲存到 localStorage
  • 應用啟動時自動檢查
  • 過期或無效時自動清除

建立了一個 AuthContext

// src/contexts/AuthContext.tsx
const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [accessToken, setAccessTokenState] = useState<string | null>(null);

  // 啟動時檢查 localStorage 中的 token
  useEffect(() => {
    const saved = localStorage.getItem("mm_access_token");
    if (!saved) return;

    try {
      const decoded = jwtDecode<{ exp?: number }>(saved);
      if (decoded?.exp && decoded.exp * 1000 < Date.now()) {
        localStorage.removeItem("mm_access_token"); // 過期清除
        return;
      }
      setAccessTokenState(saved);
    } catch {
      localStorage.removeItem("mm_access_token"); // 格式錯誤清除
    }
  }, []);

  // 更新 token 並同步 localStorage
  const setAccessToken = useCallback((token: string | null) => {
    setAccessTokenState(token);
    if (token) localStorage.setItem("mm_access_token", token);
    else localStorage.removeItem("mm_access_token");
  }, []);

  const logout = useCallback(() => setAccessToken(null), [setAccessToken]);

  return (
    <AuthContext.Provider value={{ accessToken, isAuthenticated: !!accessToken, setAccessToken, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

這段程式碼的重點

  • 啟動時檢查 localStorage → 自動登入或清除無效 token。
  • jwtDecode 檢查 token 過期時間。
  • setAccessTokenlogout 提供給元件使用。
  • useMemo / useCallback 避免不必要重渲染。

API 請求:自動帶上 Authorization

後續要打API都要加上Token,這邊寫一個簡單的服務就不用每次都手動加

// src/services/api.ts
export const useApi = () => {
  const { accessToken, logout } = useAuth();

  const headers: HeadersInit = useMemo(() => ({
    "Content-Type": "application/json",
    ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
  }), [accessToken]);

  const get = useCallback(async <T>(endpoint: string): Promise<T | null> => {
    const response = await fetch(`${API_BASE}${endpoint}`, { headers });
    if (response.status === 401) { logout(); return null; }
    return response.ok ? response.json() : null;
  }, [headers, logout]);

  return { get };
};

這段程式碼的重點

  • headers 會隨 token 自動更新。
  • 每次請求都會帶上 Bearer token。
  • 遇到 401 Unauthorized 會自動登出。
  • 其他請求方法(POST/PUT/DELETE)也能用相同邏輯封裝。

使用範例:

import { useApi } from "../services/api";

const MyComponent = () => {
  const { get } = useApi();

  const fetchActivities = async () => {
    // 請求會自動帶上 Authorization: Bearer <token>
    const activities = await get<Activity[]>("/api/activities");
    console.log(activities);
  };

  return <button onClick={fetchActivities}>載入活動</button>;
};

登出

如果有登出或是判斷到沒登入之類的,重新導入到IntroPage,讓使用者重新登入

// src/App.tsx
import { useAuth } from "./contexts/AuthContext";
import IntroPage from "./components/IntroPage";
import MainApp from "./components/MainApp";

const App = () => {
  const { isAuthenticated } = useAuth();
  return isAuthenticated ? <MainApp /> : <IntroPage />;
};

這段程式碼的重點:用 isAuthenticated 來決定顯示登入頁或主應用。

登出流程

// src/components/Header.tsx
import { useAuth } from "../contexts/AuthContext";

const Header = () => {
  const { logout } = useAuth();
  return (
    <header className="flex justify-between items-center p-4 shadow-sm border-b">
      <h1 className="text-xl font-bold">MyMomentum</h1>
      <button onClick={logout} className="px-3 py-1 border rounded">
        登出
      </button>
    </header>
  );
};

export default Header;

這段程式碼的重點:點擊「登出」會清除 token 和 localStorage,App 會自動回到登入頁。

總結

今天我們完成了 前端登入系統的完整實作,主要完成了:

  1. 在應用最外層設定 GoogleOAuthProvider,確保所有組件都能使用 Google 登入功能。
  2. 建立登入頁面,使用 GoogleLogin 按鈕取得憑證,並傳到後端換取 JWT。
  3. 實作 AuthContext,統一管理 JWT 的儲存、恢復與過期檢查。
  4. 建立 API 服務,讓所有請求自動帶上 Authorization: Bearer
  5. 使用 isAuthenticated 控制頁面顯示,並實作登出流程。

這樣下來,整個前端登入流程就完成了,從登入到 API 認證,再到登出,全部串起來。

明天就要來看 後端怎麼驗證 Google ID Token、發 JWT,前後端搭配起來,才能完成真正可用的登入機制。

完整程式碼

本篇文章的程式碼已整理在 GitHub Repository,歡迎參考:
👉 https://github.com/troublord/my-momentum/tree/dev/frontend


上一篇
登入與使用者機制 (Part 1):需求與設計思路
下一篇
後端認證實作:Google OAuth + JWT 無狀態認證系統
系列文
我的時間到底去哪裡了!? – 個人時間數據系統開發挑戰15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言