fix: app scan
This commit is contained in:
@@ -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<AuthResponse> {
|
||||
|
||||
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||
export function logout(): Promise<LogoutResponse> {
|
||||
return api.post("v1/auth/logout").json<LogoutResponse>();
|
||||
return api
|
||||
.post("v1/auth/logout")
|
||||
.json<LogoutResponse>()
|
||||
.finally(clearCsrfToken);
|
||||
}
|
||||
|
||||
@@ -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<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。
|
||||
@@ -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 }),
|
||||
});
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@
|
||||
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
||||
<!-- 表單 -->
|
||||
<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-if="errorMessage"
|
||||
class="mb-4"
|
||||
@@ -134,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
@@ -158,6 +168,11 @@ const errorMessage = ref(
|
||||
? "此手機已完成註冊,請直接輸入密碼登入。"
|
||||
: "",
|
||||
);
|
||||
const csrfRecoveryMessage = computed(() =>
|
||||
route.query.csrf === "refresh"
|
||||
? "安全驗證已失效,請重新整理頁面後再登入。"
|
||||
: "",
|
||||
);
|
||||
|
||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone(value: string) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HTTPError } from "ky";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user