fix: app scan
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user