fix: app scan

This commit is contained in:
skytek_xinliang
2026-09-16 10:45:42 +08:00
parent 057bd335d6
commit ab8da775a6
6 changed files with 170 additions and 6 deletions
+5 -2
View File
@@ -1,4 +1,4 @@
import { api } from "./client"; import { api, clearCsrfToken } from "./client";
import type { import type {
AuthResponse, AuthResponse,
LoginRequest, LoginRequest,
@@ -37,5 +37,8 @@ export function register(request: RegisterRequest): Promise<AuthResponse> {
/** 呼叫後端登出並讓後端清除登入 Cookie。 */ /** 呼叫後端登出並讓後端清除登入 Cookie。 */
export function logout(): Promise<LogoutResponse> { export function logout(): Promise<LogoutResponse> {
return api.post("v1/auth/logout").json<LogoutResponse>(); return api
.post("v1/auth/logout")
.json<LogoutResponse>()
.finally(clearCsrfToken);
} }
+122 -3
View File
@@ -1,4 +1,86 @@
import ky from "ky"; 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<string> | 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<string> {
if (csrfTokenRequest) {
return csrfTokenRequest;
}
csrfTokenRequest = csrfApi
.get("v1/auth/csrf-token")
.json<CsrfTokenResponse>()
.then((response) => {
if (!response.token) {
throw new Error("CSRF Token 回應格式不正確");
}
csrfToken = response.token;
return csrfToken;
})
.finally(() => {
csrfTokenRequest = null;
});
return csrfTokenRequest;
}
async function getCsrfToken(): Promise<string> {
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<boolean> {
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。 * 全站共用的 HTTP client。
@@ -6,19 +88,56 @@ import ky from "ky";
*/ */
export const api = ky.create({ export const api = ky.create({
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋 // prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
prefix: import.meta.env.VITE_API_BASE_URL || "/api", prefix: API_PREFIX,
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。 // 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
credentials: "include", credentials: "include",
timeout: 10_000, timeout: 10_000,
retry: { limit: 2 }, retry: { limit: 2 },
hooks: { hooks: {
beforeRequest: [
async ({ request }) => {
if (!requiresCsrfToken(request.method)) {
return;
}
request.headers.set(CSRF_TOKEN_HEADER, await getCsrfToken());
},
],
afterResponse: [ afterResponse: [
({ response }) => { async ({ request, response, options, retryCount }) => {
if (response.status === 401 && typeof window !== "undefined") { if (response.status === 401 && typeof window !== "undefined") {
clearCsrfToken();
window.dispatchEvent(new Event("auth-expired")); 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 }),
});
}, },
], ],
}, },
+5
View File
@@ -2,6 +2,11 @@
export const CONSENT_VERSION = "v1" as const; export const CONSENT_VERSION = "v1" as const;
export type ConsentVersion = typeof CONSENT_VERSION; export type ConsentVersion = typeof CONSENT_VERSION;
/** 後端防偽端點回傳的 Request Token;僅保存於前端記憶體。 */
export interface CsrfTokenResponse {
token: string;
}
/** 體適能組別 */ /** 體適能組別 */
export type FitnessGroup = "low" | "medium" | "high"; export type FitnessGroup = "low" | "medium" | "high";
+16 -1
View File
@@ -21,6 +21,16 @@
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text> <v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
<!-- 表單 --> <!-- 表單 -->
<v-form class="pa-4" @submit.prevent="handleLogin"> <v-form class="pa-4" @submit.prevent="handleLogin">
<v-alert
v-if="csrfRecoveryMessage"
class="mb-4"
density="compact"
type="warning"
variant="tonal"
>
{{ csrfRecoveryMessage }}
</v-alert>
<v-alert <v-alert
v-if="errorMessage" v-if="errorMessage"
class="mb-4" class="mb-4"
@@ -134,7 +144,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import BrandAvatar from "@/components/BrandAvatar.vue"; import BrandAvatar from "@/components/BrandAvatar.vue";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
@@ -158,6 +168,11 @@ const errorMessage = ref(
? "此手機已完成註冊,請直接輸入密碼登入。" ? "此手機已完成註冊,請直接輸入密碼登入。"
: "", : "",
); );
const csrfRecoveryMessage = computed(() =>
route.query.csrf === "refresh"
? "安全驗證已失效,請重新整理頁面後再登入。"
: "",
);
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */ /** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
function normalizePhone(value: string) { function normalizePhone(value: string) {
+21
View File
@@ -154,6 +154,27 @@ if (typeof window !== "undefined") {
}); });
} }
}); });
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
window.addEventListener("csrf-recovery-required", () => {
const store = useAppStore();
const courseStore = useCourseStore();
const currentRoute = router.currentRoute.value;
const redirect =
currentRoute.name === "login"
? String(currentRoute.query.redirect ?? "")
: currentRoute.fullPath;
store.clearAuthentication();
courseStore.resetGroup();
void router.replace({
name: "login",
query: {
...(redirect ? { redirect } : {}),
csrf: "refresh",
},
});
});
} }
export default router; export default router;
@@ -1,6 +1,7 @@
import { HTTPError } from "ky"; import { HTTPError } from "ky";
export interface ApiErrorBody { export interface ApiErrorBody {
code?: string;
message?: string; message?: string;
errors?: Record<string, string[]>; errors?: Record<string, string[]>;
} }