feat(auth): 重构认证逻辑,支持 HttpOnly Cookie 登录;移除不再使用的 Token 存储方式
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-06-30 11:41:57 +08:00
parent ba77dc1f5a
commit 6e248207ff
16 changed files with 193 additions and 118 deletions

View File

@@ -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",

View File

@@ -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 {
<SidebarMenu className="h-full w-full">
<SidebarMenuItem className="h-full">
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
<img
<Image
src="/logo.png"
alt="N lotto"
width={160}
height={40}
priority
className="h-auto max-h-10 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
/>
</div>
@@ -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
>
<img
<Image
src="/image6.png"
alt=""
fill
sizes="var(--sidebar-width)"
className="h-full w-full object-cover object-bottom"
/>
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
@@ -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"
>
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
<img
<Image
src="/logo.png"
alt="N lotto"
width={160}
height={40}
priority
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
/>
</div>
@@ -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
>
<img
<Image
src="/image6.png"
alt=""
fill
sizes="var(--sidebar-width)"
className="h-full w-full object-cover object-bottom"
/>
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />

View File

@@ -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");
}

View File

@@ -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 ? (
<img
<Image
src={captchaSrc}
alt=""
width={160}
height={48}
unoptimized
className="pointer-events-none block h-full w-auto max-w-full object-contain"
/>
) : (

View File

@@ -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");

View File

@@ -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(

View File

@@ -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 };

View File

@@ -125,3 +125,11 @@ export const adminRequest = {
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
) => request<T>({ ...config, url, method: "PUT", data }),
};
export async function clearAdminSessionCookie(): Promise<void> {
await adminHttp.request({
url: "/admin/auth/logout",
method: "POST",
validateStatus: () => true,
});
}

View File

@@ -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<boolean> {
const token = readToken();
if (!token) {
return false;
}
const session = getAdminSessionState();
session.setBearerToken(token);
session.setBearerToken("__cookie__");
try {
const result = await fetchAdminMeDeduped();

View File

@@ -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 天对齐(秒) */

View File

@@ -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,
};
}

View File

@@ -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<NextResponse> {
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<NextResponse> {
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;
}

View File

@@ -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")) {

View File

@@ -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<void>;
/** @deprecated 使用 {@link clearSession} */
@@ -70,24 +70,13 @@ export const useAdminSessionStore = create<AdminSessionState>((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);

View File

@@ -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;
}

View File

@@ -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;
};