From ab8da775a625bc575c6225fe3217b3ff0907969e Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Wed, 16 Sep 2026 10:45:42 +0800 Subject: [PATCH] fix: app scan --- app/citizen-frontend/src/api/auth.ts | 7 +- app/citizen-frontend/src/api/client.ts | 125 +++++++++++++++++++- app/citizen-frontend/src/api/types.ts | 5 + app/citizen-frontend/src/pages/login.vue | 17 ++- app/citizen-frontend/src/router/index.ts | 21 ++++ app/citizen-frontend/src/utils/api-error.ts | 1 + 6 files changed, 170 insertions(+), 6 deletions(-) diff --git a/app/citizen-frontend/src/api/auth.ts b/app/citizen-frontend/src/api/auth.ts index fa08e5e..250b397 100644 --- a/app/citizen-frontend/src/api/auth.ts +++ b/app/citizen-frontend/src/api/auth.ts @@ -1,4 +1,4 @@ -import { api } from "./client"; +import { api, clearCsrfToken } from "./client"; import type { AuthResponse, LoginRequest, @@ -37,5 +37,8 @@ export function register(request: RegisterRequest): Promise { /** 呼叫後端登出並讓後端清除登入 Cookie。 */ export function logout(): Promise { - return api.post("v1/auth/logout").json(); + return api + .post("v1/auth/logout") + .json() + .finally(clearCsrfToken); } diff --git a/app/citizen-frontend/src/api/client.ts b/app/citizen-frontend/src/api/client.ts index aee609a..5487ad0 100644 --- a/app/citizen-frontend/src/api/client.ts +++ b/app/citizen-frontend/src/api/client.ts @@ -1,4 +1,86 @@ import ky from "ky"; +import type { CsrfTokenResponse } from "./types"; + +const API_PREFIX = import.meta.env.VITE_API_BASE_URL || "/api"; +const CSRF_TOKEN_HEADER = "X-CSRF-TOKEN"; +const CSRF_ERROR_HEADER = "X-CSRF-Error"; +const CSRF_VALIDATION_ERROR_CODE = "CSRF_VALIDATION_FAILED"; +const CSRF_RETRY_CONTEXT_KEY = "csrfRetryAttempted"; +const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +let csrfToken: string | null = null; +let csrfTokenRequest: Promise | null = null; + +const csrfApi = ky.create({ + prefix: API_PREFIX, + credentials: "include", + timeout: 10_000, + retry: { limit: 2 }, +}); + +function requiresCsrfToken(method: string): boolean { + return STATE_CHANGING_METHODS.has(method.toUpperCase()); +} + +async function refreshCsrfToken(): Promise { + if (csrfTokenRequest) { + return csrfTokenRequest; + } + + csrfTokenRequest = csrfApi + .get("v1/auth/csrf-token") + .json() + .then((response) => { + if (!response.token) { + throw new Error("CSRF Token 回應格式不正確"); + } + + csrfToken = response.token; + return csrfToken; + }) + .finally(() => { + csrfTokenRequest = null; + }); + + return csrfTokenRequest; +} + +async function getCsrfToken(): Promise { + return csrfToken ?? refreshCsrfToken(); +} + +/** 登出後清除記憶體內的 Token,下一次狀態變更會重新取得。 */ +export function clearCsrfToken() { + csrfToken = null; +} + +function hasCsrfValidationErrorCode(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + "code" in body && + body.code === CSRF_VALIDATION_ERROR_CODE + ); +} + +async function isCsrfValidationFailure(response: Response): Promise { + if (response.headers.get(CSRF_ERROR_HEADER)?.toLowerCase() === "true") { + return true; + } + + return hasCsrfValidationErrorCode( + await response + .clone() + .json() + .catch(() => null), + ); +} + +function requireCsrfRecovery() { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event("csrf-recovery-required")); + } +} /** * 全站共用的 HTTP client。 @@ -6,19 +88,56 @@ import ky from "ky"; */ export const api = ky.create({ // prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋 - prefix: import.meta.env.VITE_API_BASE_URL || "/api", + prefix: API_PREFIX, // 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。 credentials: "include", timeout: 10_000, retry: { limit: 2 }, hooks: { + beforeRequest: [ + async ({ request }) => { + if (!requiresCsrfToken(request.method)) { + return; + } + + request.headers.set(CSRF_TOKEN_HEADER, await getCsrfToken()); + }, + ], afterResponse: [ - ({ response }) => { + async ({ request, response, options, retryCount }) => { if (response.status === 401 && typeof window !== "undefined") { + clearCsrfToken(); window.dispatchEvent(new Event("auth-expired")); } - return response; + if ( + !requiresCsrfToken(request.method) || + response.status !== 400 || + !(await isCsrfValidationFailure(response)) + ) { + return; + } + + if (options.context[CSRF_RETRY_CONTEXT_KEY] === true) { + clearCsrfToken(); + requireCsrfRecovery(); + return; + } + + options.context[CSRF_RETRY_CONTEXT_KEY] = true; + // Ky 將網路重試與強制重試共用計數;保留一次額度給 CSRF 專用重送。 + options.retry.limit = Math.max( + options.retry.limit ?? 0, + retryCount + 1, + ); + clearCsrfToken(); + const headers = new Headers(request.headers); + headers.set(CSRF_TOKEN_HEADER, await refreshCsrfToken()); + + return ky.retry({ + code: CSRF_VALIDATION_ERROR_CODE, + request: new Request(request, { headers }), + }); }, ], }, diff --git a/app/citizen-frontend/src/api/types.ts b/app/citizen-frontend/src/api/types.ts index e4232b7..4d06ef9 100644 --- a/app/citizen-frontend/src/api/types.ts +++ b/app/citizen-frontend/src/api/types.ts @@ -2,6 +2,11 @@ export const CONSENT_VERSION = "v1" as const; export type ConsentVersion = typeof CONSENT_VERSION; +/** 後端防偽端點回傳的 Request Token;僅保存於前端記憶體。 */ +export interface CsrfTokenResponse { + token: string; +} + /** 體適能組別 */ export type FitnessGroup = "low" | "medium" | "high"; diff --git a/app/citizen-frontend/src/pages/login.vue b/app/citizen-frontend/src/pages/login.vue index 78c7091..c008ca3 100644 --- a/app/citizen-frontend/src/pages/login.vue +++ b/app/citizen-frontend/src/pages/login.vue @@ -21,6 +21,16 @@ 請輸入您的手機門號與登入密碼 + + {{ csrfRecoveryMessage }} + +