diff --git a/src/components/admin/admin-numeric-stepper.tsx b/src/components/admin/admin-numeric-stepper.tsx index 8e6858f..690a7af 100644 --- a/src/components/admin/admin-numeric-stepper.tsx +++ b/src/components/admin/admin-numeric-stepper.tsx @@ -136,8 +136,6 @@ export function AdminNumericStepper({ inputMode={integer ? "numeric" : "decimal"} value={value} disabled={disabled} - aria-valuemin={min} - aria-valuemax={max} aria-invalid={outOfRange || undefined} className={cn( "h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none", diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index b9baaba..50a278b 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -1,8 +1,8 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { useMemo, type ReactElement } from "react"; -import { useTranslation } from "react-i18next"; import { AdminSidebarNav, @@ -28,9 +28,12 @@ function AdminSidebarSkeleton(): ReactElement {
- N lotto
@@ -42,9 +45,11 @@ function AdminSidebarSkeleton(): ReactElement { className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden" aria-hidden > -
@@ -83,9 +88,12 @@ export function AdminAppSidebar() { className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center" >
- N lotto
@@ -107,9 +115,11 @@ export function AdminAppSidebar() { className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden" aria-hidden > -
diff --git a/src/components/admin/auth-gate.tsx b/src/components/admin/auth-gate.tsx index 348bfd5..e04f442 100644 --- a/src/components/admin/auth-gate.tsx +++ b/src/components/admin/auth-gate.tsx @@ -6,7 +6,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking"; import { verifyStoredAdminSession } from "@/lib/admin-session-verify"; import { useAdminSessionStore } from "@/stores/admin-session"; -import { readToken } from "@/stores/admin-token"; type ShellAuthGateProps = { children: ReactNode; @@ -14,13 +13,8 @@ type ShellAuthGateProps = { type GateStatus = "pending" | "authed" | "guest"; -function hasAdminToken(bearerToken: string | null): boolean { - const token = bearerToken ?? readToken(); - return token != null && token.trim() !== ""; -} - /** - * Shell 路由守卫:无 Token 或 `/auth/me` 校验失败时跳转登录页。 + * Shell 路由守卫:无有效 HttpOnly Cookie 会话或 `/auth/me` 校验失败时跳转登录页。 */ export function ShellAuthGate({ children }: ShellAuthGateProps) { const router = useRouter(); @@ -34,13 +28,6 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) { setShellAuthPending(true); async function run() { - if (!hasAdminToken(bearerToken)) { - if (!cancelled) { - setStatus("guest"); - } - return; - } - if (!cancelled) { setStatus("pending"); } diff --git a/src/components/admin/login-form.tsx b/src/components/admin/login-form.tsx index 6dcfd2d..6b61c73 100644 --- a/src/components/admin/login-form.tsx +++ b/src/components/admin/login-form.tsx @@ -21,7 +21,6 @@ import { validateAdminLoginAccount, validateAdminPassword, } from "@/lib/admin-input-validation"; -import { readToken } from "@/stores/admin-token"; import { authModuleMeta } from "@/modules/auth/meta"; import { useAdminSessionStore } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -60,20 +59,12 @@ export function LoginForm() { } finally { setLoadingCaptcha(false); } - }, []); + }, [tRef]); useEffect(() => { let cancelled = false; async function bootstrap() { - if (!readToken()) { - if (!cancelled) { - setCheckingSession(false); - void loadCaptcha(); - } - return; - } - const ok = await verifyStoredAdminSession(); if (cancelled) { return; @@ -131,7 +122,7 @@ export function LoginForm() { captcha_key: captchaKey, captcha_code: captchaCode.trim(), }); - setBearerToken(result.token); + setBearerToken(result.token ?? "__cookie__"); setAdminProfile(result.admin); toast.success( t("welcome", { name: result.admin.nickname || result.admin.username }), @@ -278,11 +269,12 @@ export function LoginForm() { aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")} > {captchaSrc ? ( - ) : ( diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx index f85495d..c3202ac 100644 --- a/src/components/admin/toolbar.tsx +++ b/src/components/admin/toolbar.tsx @@ -22,6 +22,7 @@ import { useAdminProfile, useAdminSessionStore, } from "@/stores/admin-session"; +import { clearAdminSessionCookie } from "@/lib/admin-http"; export function ShellToolbar() { const { t } = useTranslation("common"); @@ -35,6 +36,7 @@ export function ShellToolbar() { t("toolbar.defaultAdmin"); function onLogout() { + void clearAdminSessionCookie(); clearSession(); toast.success(t("toolbar.loggedOut")); router.replace("/admin/login"); diff --git a/src/lib/admin-auth-reject.ts b/src/lib/admin-auth-reject.ts index 035ec9f..b59ec42 100644 --- a/src/lib/admin-auth-reject.ts +++ b/src/lib/admin-auth-reject.ts @@ -102,8 +102,12 @@ export function handleAdminAuthRejected(): void { authRejectHandling = true; - const hadSession = readToken() != null; - getAdminSessionState().clearSession(); + const session = getAdminSessionState(); + const hadSession = session.bearerToken != null || readToken() != null; + void fetch("/api/v1/admin/auth/logout", { method: "POST" }).catch(() => { + // Best-effort HttpOnly cookie cleanup; the local session is cleared below. + }); + session.clearSession(); if (hadSession) { toast.error( diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index f844338..4e200a9 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -20,7 +20,7 @@ export function getAdminBearerTokenPayload(): string | null { export function withAdminAuthHeader( config: AxiosRequestConfig, ): AxiosRequestConfig { - if (!adminBearerPayload) { + if (!adminBearerPayload || adminBearerPayload === "__cookie__") { return config; } const merged: AxiosRequestConfig = { ...config }; diff --git a/src/lib/admin-http.ts b/src/lib/admin-http.ts index f947a4d..056bffc 100644 --- a/src/lib/admin-http.ts +++ b/src/lib/admin-http.ts @@ -125,3 +125,11 @@ export const adminRequest = { config?: Omit, ) => request({ ...config, url, method: "PUT", data }), }; + +export async function clearAdminSessionCookie(): Promise { + await adminHttp.request({ + url: "/admin/auth/logout", + method: "POST", + validateStatus: () => true, + }); +} diff --git a/src/lib/admin-session-verify.ts b/src/lib/admin-session-verify.ts index 491b1c4..e06aef3 100644 --- a/src/lib/admin-session-verify.ts +++ b/src/lib/admin-session-verify.ts @@ -1,19 +1,13 @@ import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me"; import { isAdminAuthRejected } from "@/lib/admin-auth-reject"; import { getAdminSessionState } from "@/stores/admin-session"; -import { readToken } from "@/stores/admin-token"; /** - * 用 `/auth/me` 校验本地 Token 是否仍有效;失败时清会话(不跳转,由调用方决定)。 + * 用 `/auth/me` 校验 HttpOnly Cookie 会话是否仍有效;失败时清会话(不跳转,由调用方决定)。 */ export async function verifyStoredAdminSession(): Promise { - const token = readToken(); - if (!token) { - return false; - } - const session = getAdminSessionState(); - session.setBearerToken(token); + session.setBearerToken("__cookie__"); try { const result = await fetchAdminMeDeduped(); diff --git a/src/lib/admin-token-constants.ts b/src/lib/admin-token-constants.ts index 436b45f..28a1124 100644 --- a/src/lib/admin-token-constants.ts +++ b/src/lib/admin-token-constants.ts @@ -1,4 +1,4 @@ -/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */ +/** HttpOnly Cookie 键名,须与 middleware / API proxy 一致 */ export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token"; /** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */ diff --git a/src/lib/admin-token-cookie.ts b/src/lib/admin-token-cookie.ts index d8a9a68..55eb022 100644 --- a/src/lib/admin-token-cookie.ts +++ b/src/lib/admin-token-cookie.ts @@ -3,6 +3,13 @@ import { ADMIN_TOKEN_STORAGE_KEY, } from "@/lib/admin-token-constants"; +const COOKIE_BASE_OPTIONS = { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS, +} as const; + export function readAdminTokenFromCookieString( cookieHeader: string | null | undefined, ): string | null { @@ -32,30 +39,24 @@ export function readAdminTokenFromCookieString( } export function readAdminTokenFromDocumentCookie(): string | null { - if (typeof document === "undefined") { - return null; - } - - return readAdminTokenFromCookieString(document.cookie); + return null; } export function writeAdminTokenCookie(token: string | null): void { - if (typeof document === "undefined") { - return; - } - - const secure = - typeof window !== "undefined" && window.location.protocol === "https:"; - const base = `path=/; SameSite=Lax`; - - if (!token || token.trim() === "") { - document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`; - return; - } - - const value = encodeURIComponent(token.trim()); - const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`; - document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${ - secure ? "; Secure" : "" - }`; + void token; +} + +export function adminTokenCookieOptions(secure: boolean) { + return { + ...COOKIE_BASE_OPTIONS, + secure, + }; +} + +export function expiredAdminTokenCookieOptions(secure: boolean) { + return { + ...COOKIE_BASE_OPTIONS, + secure, + maxAge: 0, + }; } diff --git a/src/lib/lottery-api-proxy.ts b/src/lib/lottery-api-proxy.ts index 8b8e8a3..4feb652 100644 --- a/src/lib/lottery-api-proxy.ts +++ b/src/lib/lottery-api-proxy.ts @@ -1,5 +1,11 @@ import { type NextRequest, NextResponse } from "next/server"; +import { + adminTokenCookieOptions, + expiredAdminTokenCookieOptions, + readAdminTokenFromCookieString, +} from "@/lib/admin-token-cookie"; +import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants"; import { lotteryApiOrigin } from "@/lib/lottery-api-base"; const HOP_BY_HOP = new Set([ @@ -27,12 +33,23 @@ export async function proxyLotteryApi( ): Promise { const path = pathSegments.join("/"); const target = `${lotteryApiOrigin()}/api/${path}${request.nextUrl.search}`; + const isAdminAuthLogin = + request.method === "POST" && path === "v1/admin/auth/login"; + const isAdminLogout = + request.method === "POST" && path === "v1/admin/auth/logout"; const headers = new Headers(request.headers); headers.delete("host"); headers.delete("connection"); headers.delete("accept-encoding"); + const cookieToken = readAdminTokenFromCookieString( + request.headers.get("cookie"), + ); + if (cookieToken && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${cookieToken}`); + } + const init: RequestInit = { method: request.method, headers, @@ -61,9 +78,108 @@ export async function proxyLotteryApi( responseHeaders.delete(name); } - return new NextResponse(upstream.body, { + if (isAdminAuthLogin) { + return handleAdminLoginResponse(request, upstream, responseHeaders); + } + + const response = new NextResponse(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders, }); + + if (isAdminLogout) { + response.cookies.set( + ADMIN_TOKEN_STORAGE_KEY, + "", + expiredAdminTokenCookieOptions(isSecureRequest(request)), + ); + } + + return response; +} + +async function handleAdminLoginResponse( + request: NextRequest, + upstream: Response, + responseHeaders: Headers, +): Promise { + let payload: unknown; + try { + payload = await upstream.json(); + } catch { + const response = NextResponse.json( + { msg: "Invalid login response from upstream" }, + { status: 502 }, + ); + response.cookies.set( + ADMIN_TOKEN_STORAGE_KEY, + "", + expiredAdminTokenCookieOptions(request.nextUrl.protocol === "https:"), + ); + return response; + } + + const token = extractAdminToken(payload); + if (token && upstream.ok) { + stripAdminToken(payload); + } + + responseHeaders.delete("content-type"); + const response = NextResponse.json(payload, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); + + const secure = isSecureRequest(request); + if (token && upstream.ok) { + response.cookies.set( + ADMIN_TOKEN_STORAGE_KEY, + token, + adminTokenCookieOptions(secure), + ); + } else { + response.cookies.set( + ADMIN_TOKEN_STORAGE_KEY, + "", + expiredAdminTokenCookieOptions(secure), + ); + } + + return response; +} + +function isSecureRequest(request: NextRequest): boolean { + return ( + request.nextUrl.protocol === "https:" || + request.headers.get("x-forwarded-proto") === "https" + ); +} + +function extractAdminToken(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || !("data" in payload)) { + return null; + } + + const data = (payload as { data?: unknown }).data; + if (!data || typeof data !== "object" || !("token" in data)) { + return null; + } + + const token = (data as { token?: unknown }).token; + return typeof token === "string" && token.trim() !== "" ? token.trim() : null; +} + +function stripAdminToken(payload: unknown): void { + if (!payload || typeof payload !== "object" || !("data" in payload)) { + return; + } + + const data = (payload as { data?: unknown }).data; + if (!data || typeof data !== "object") { + return; + } + + delete (data as { token?: unknown }).token; } diff --git a/src/middleware.ts b/src/proxy.ts similarity index 95% rename from src/middleware.ts rename to src/proxy.ts index 7b56f58..ebc1ce9 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -18,7 +18,7 @@ function readTokenFromRequest(request: NextRequest): string | null { return readAdminTokenFromCookieString(request.headers.get("cookie")); } -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; if (!pathname.startsWith("/admin")) { diff --git a/src/stores/admin-session.ts b/src/stores/admin-session.ts index 3bb0692..5a54e15 100644 --- a/src/stores/admin-session.ts +++ b/src/stores/admin-session.ts @@ -1,5 +1,5 @@ /** - * 管理端会话:Bearer Token + 登录接口返回的 {@link AdminProfile}。 + * 管理端会话:HttpOnly Cookie 登录态 + 登录接口返回的 {@link AdminProfile}。 * * - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)` * - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()` @@ -13,7 +13,7 @@ import { isAdminAuthRejected, } from "@/lib/admin-auth-reject"; import { readProfile, writeProfile } from "@/stores/admin-profile"; -import { readToken, writeToken } from "@/stores/admin-token"; +import { writeToken } from "@/stores/admin-token"; import type { AdminProfile } from "@/types/api/admin-auth"; export type AdminSessionState = { @@ -25,7 +25,7 @@ export type AdminSessionState = { setBearerToken: (token: string | null) => void; setAdminProfile: (profile: AdminProfile | null) => void; clearSession: () => void; - /** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */ + /** 从 HttpOnly Cookie 会话恢复管理员摘要(仅客户端) */ rehydrate: () => void; refreshAdminProfile: () => Promise; /** @deprecated 使用 {@link clearSession} */ @@ -70,24 +70,13 @@ export const useAdminSessionStore = create((set, get) => ({ if (typeof window === "undefined") { return; } - const token = readToken(); const profile = readProfile(); - if (token) { - setAdminBearerToken(token); - set({ bearerToken: token, adminProfile: profile }); - void get().refreshAdminProfile(); - } else { - setAdminBearerToken(null); - set({ bearerToken: null, adminProfile: null }); - } + setAdminBearerToken(null); + set({ bearerToken: "__cookie__", adminProfile: profile }); + void get().refreshAdminProfile(); }, refreshAdminProfile: async () => { - const token = get().bearerToken ?? readToken(); - if (!token) { - return; - } - try { const result = await fetchAdminMeDeduped(); writeProfile(result.admin); diff --git a/src/stores/admin-token.ts b/src/stores/admin-token.ts index 3cf31f6..ad5c256 100644 --- a/src/stores/admin-token.ts +++ b/src/stores/admin-token.ts @@ -1,33 +1,7 @@ -import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants"; -import { - readAdminTokenFromDocumentCookie, - writeAdminTokenCookie, -} from "@/lib/admin-token-cookie"; - export function readToken(): string | null { - if (typeof window === "undefined") { - return null; - } - - const fromStorage = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim(); - if (fromStorage) { - return fromStorage; - } - - return readAdminTokenFromDocumentCookie(); + return null; } export function writeToken(t: string | null): void { - if (typeof window === "undefined") { - return; - } - - if (t && t.trim() !== "") { - const normalized = t.trim(); - window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, normalized); - writeAdminTokenCookie(normalized); - } else { - window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); - writeAdminTokenCookie(null); - } + void t; } diff --git a/src/types/api/admin-auth.ts b/src/types/api/admin-auth.ts index e634603..3bb4300 100644 --- a/src/types/api/admin-auth.ts +++ b/src/types/api/admin-auth.ts @@ -25,7 +25,7 @@ export type AdminAccountKind = | "agent_operator" | "platform_account"; -/** 登录成功后缓存于会话(localStorage)的管理员摘要 */ +/** 登录成功后缓存于会话的管理员摘要 */ export type AdminProfile = { id: number; username: string; @@ -52,8 +52,8 @@ export type AdminProfile = { /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ export type AdminAuthLoginResponse = { - token: string; - token_type: string; + token?: string; + token_type?: string; admin: AdminProfile; };