feat(auth): 重构认证逻辑,支持 HttpOnly Cookie 登录;移除不再使用的 Token 存储方式
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
This commit is contained in:
@@ -136,8 +136,6 @@ export function AdminNumericStepper({
|
|||||||
inputMode={integer ? "numeric" : "decimal"}
|
inputMode={integer ? "numeric" : "decimal"}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-valuemin={min}
|
|
||||||
aria-valuemax={max}
|
|
||||||
aria-invalid={outOfRange || undefined}
|
aria-invalid={outOfRange || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",
|
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AdminSidebarNav,
|
AdminSidebarNav,
|
||||||
@@ -28,9 +28,12 @@ function AdminSidebarSkeleton(): ReactElement {
|
|||||||
<SidebarMenu className="h-full w-full">
|
<SidebarMenu className="h-full w-full">
|
||||||
<SidebarMenuItem className="h-full">
|
<SidebarMenuItem className="h-full">
|
||||||
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||||
<img
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
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"
|
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>
|
</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"
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src="/image6.png"
|
src="/image6.png"
|
||||||
alt=""
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="var(--sidebar-width)"
|
||||||
className="h-full w-full object-cover object-bottom"
|
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" />
|
<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"
|
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">
|
<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"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
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"
|
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>
|
</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"
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src="/image6.png"
|
src="/image6.png"
|
||||||
alt=""
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="var(--sidebar-width)"
|
||||||
className="h-full w-full object-cover object-bottom"
|
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" />
|
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
|
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
|
||||||
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { readToken } from "@/stores/admin-token";
|
|
||||||
|
|
||||||
type ShellAuthGateProps = {
|
type ShellAuthGateProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -14,13 +13,8 @@ type ShellAuthGateProps = {
|
|||||||
|
|
||||||
type GateStatus = "pending" | "authed" | "guest";
|
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) {
|
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -34,13 +28,6 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
|||||||
setShellAuthPending(true);
|
setShellAuthPending(true);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!hasAdminToken(bearerToken)) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setStatus("guest");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatus("pending");
|
setStatus("pending");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
validateAdminLoginAccount,
|
validateAdminLoginAccount,
|
||||||
validateAdminPassword,
|
validateAdminPassword,
|
||||||
} from "@/lib/admin-input-validation";
|
} from "@/lib/admin-input-validation";
|
||||||
import { readToken } from "@/stores/admin-token";
|
|
||||||
import { authModuleMeta } from "@/modules/auth/meta";
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -60,20 +59,12 @@ export function LoginForm() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingCaptcha(false);
|
setLoadingCaptcha(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [tRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
if (!readToken()) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setCheckingSession(false);
|
|
||||||
void loadCaptcha();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await verifyStoredAdminSession();
|
const ok = await verifyStoredAdminSession();
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -131,7 +122,7 @@ export function LoginForm() {
|
|||||||
captcha_key: captchaKey,
|
captcha_key: captchaKey,
|
||||||
captcha_code: captchaCode.trim(),
|
captcha_code: captchaCode.trim(),
|
||||||
});
|
});
|
||||||
setBearerToken(result.token);
|
setBearerToken(result.token ?? "__cookie__");
|
||||||
setAdminProfile(result.admin);
|
setAdminProfile(result.admin);
|
||||||
toast.success(
|
toast.success(
|
||||||
t("welcome", { name: result.admin.nickname || result.admin.username }),
|
t("welcome", { name: result.admin.nickname || result.admin.username }),
|
||||||
@@ -278,11 +269,12 @@ export function LoginForm() {
|
|||||||
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
||||||
>
|
>
|
||||||
{captchaSrc ? (
|
{captchaSrc ? (
|
||||||
<img
|
<Image
|
||||||
src={captchaSrc}
|
src={captchaSrc}
|
||||||
alt=""
|
alt=""
|
||||||
width={160}
|
width={160}
|
||||||
height={48}
|
height={48}
|
||||||
|
unoptimized
|
||||||
className="pointer-events-none block h-full w-auto max-w-full object-contain"
|
className="pointer-events-none block h-full w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useAdminProfile,
|
useAdminProfile,
|
||||||
useAdminSessionStore,
|
useAdminSessionStore,
|
||||||
} from "@/stores/admin-session";
|
} from "@/stores/admin-session";
|
||||||
|
import { clearAdminSessionCookie } from "@/lib/admin-http";
|
||||||
|
|
||||||
export function ShellToolbar() {
|
export function ShellToolbar() {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
@@ -35,6 +36,7 @@ export function ShellToolbar() {
|
|||||||
t("toolbar.defaultAdmin");
|
t("toolbar.defaultAdmin");
|
||||||
|
|
||||||
function onLogout() {
|
function onLogout() {
|
||||||
|
void clearAdminSessionCookie();
|
||||||
clearSession();
|
clearSession();
|
||||||
toast.success(t("toolbar.loggedOut"));
|
toast.success(t("toolbar.loggedOut"));
|
||||||
router.replace("/admin/login");
|
router.replace("/admin/login");
|
||||||
|
|||||||
@@ -102,8 +102,12 @@ export function handleAdminAuthRejected(): void {
|
|||||||
|
|
||||||
authRejectHandling = true;
|
authRejectHandling = true;
|
||||||
|
|
||||||
const hadSession = readToken() != null;
|
const session = getAdminSessionState();
|
||||||
getAdminSessionState().clearSession();
|
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) {
|
if (hadSession) {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function getAdminBearerTokenPayload(): string | null {
|
|||||||
export function withAdminAuthHeader(
|
export function withAdminAuthHeader(
|
||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
): AxiosRequestConfig {
|
): AxiosRequestConfig {
|
||||||
if (!adminBearerPayload) {
|
if (!adminBearerPayload || adminBearerPayload === "__cookie__") {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
const merged: AxiosRequestConfig = { ...config };
|
const merged: AxiosRequestConfig = { ...config };
|
||||||
|
|||||||
@@ -125,3 +125,11 @@ export const adminRequest = {
|
|||||||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||||||
) => request<T>({ ...config, url, method: "PUT", 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
|
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
|
||||||
import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
|
import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
|
||||||
import { getAdminSessionState } from "@/stores/admin-session";
|
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> {
|
export async function verifyStoredAdminSession(): Promise<boolean> {
|
||||||
const token = readToken();
|
|
||||||
if (!token) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = getAdminSessionState();
|
const session = getAdminSessionState();
|
||||||
session.setBearerToken(token);
|
session.setBearerToken("__cookie__");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchAdminMeDeduped();
|
const result = await fetchAdminMeDeduped();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */
|
/** HttpOnly Cookie 键名,须与 middleware / API proxy 一致 */
|
||||||
export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
|
export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
|
||||||
|
|
||||||
/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */
|
/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import {
|
|||||||
ADMIN_TOKEN_STORAGE_KEY,
|
ADMIN_TOKEN_STORAGE_KEY,
|
||||||
} from "@/lib/admin-token-constants";
|
} 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(
|
export function readAdminTokenFromCookieString(
|
||||||
cookieHeader: string | null | undefined,
|
cookieHeader: string | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -32,30 +39,24 @@ export function readAdminTokenFromCookieString(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readAdminTokenFromDocumentCookie(): string | null {
|
export function readAdminTokenFromDocumentCookie(): string | null {
|
||||||
if (typeof document === "undefined") {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return readAdminTokenFromCookieString(document.cookie);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeAdminTokenCookie(token: string | null): void {
|
export function writeAdminTokenCookie(token: string | null): void {
|
||||||
if (typeof document === "undefined") {
|
void token;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
export function adminTokenCookieOptions(secure: boolean) {
|
||||||
const secure =
|
return {
|
||||||
typeof window !== "undefined" && window.location.protocol === "https:";
|
...COOKIE_BASE_OPTIONS,
|
||||||
const base = `path=/; SameSite=Lax`;
|
secure,
|
||||||
|
};
|
||||||
if (!token || token.trim() === "") {
|
}
|
||||||
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`;
|
|
||||||
return;
|
export function expiredAdminTokenCookieOptions(secure: boolean) {
|
||||||
}
|
return {
|
||||||
|
...COOKIE_BASE_OPTIONS,
|
||||||
const value = encodeURIComponent(token.trim());
|
secure,
|
||||||
const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`;
|
maxAge: 0,
|
||||||
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${
|
};
|
||||||
secure ? "; Secure" : ""
|
|
||||||
}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
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";
|
import { lotteryApiOrigin } from "@/lib/lottery-api-base";
|
||||||
|
|
||||||
const HOP_BY_HOP = new Set([
|
const HOP_BY_HOP = new Set([
|
||||||
@@ -27,12 +33,23 @@ export async function proxyLotteryApi(
|
|||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
const path = pathSegments.join("/");
|
const path = pathSegments.join("/");
|
||||||
const target = `${lotteryApiOrigin()}/api/${path}${request.nextUrl.search}`;
|
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);
|
const headers = new Headers(request.headers);
|
||||||
headers.delete("host");
|
headers.delete("host");
|
||||||
headers.delete("connection");
|
headers.delete("connection");
|
||||||
headers.delete("accept-encoding");
|
headers.delete("accept-encoding");
|
||||||
|
|
||||||
|
const cookieToken = readAdminTokenFromCookieString(
|
||||||
|
request.headers.get("cookie"),
|
||||||
|
);
|
||||||
|
if (cookieToken && !headers.has("authorization")) {
|
||||||
|
headers.set("authorization", `Bearer ${cookieToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
@@ -61,9 +78,108 @@ export async function proxyLotteryApi(
|
|||||||
responseHeaders.delete(name);
|
responseHeaders.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(upstream.body, {
|
if (isAdminAuthLogin) {
|
||||||
|
return handleAdminLoginResponse(request, upstream, responseHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new NextResponse(upstream.body, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
statusText: upstream.statusText,
|
statusText: upstream.statusText,
|
||||||
headers: responseHeaders,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function readTokenFromRequest(request: NextRequest): string | null {
|
|||||||
return readAdminTokenFromCookieString(request.headers.get("cookie"));
|
return readAdminTokenFromCookieString(request.headers.get("cookie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
if (!pathname.startsWith("/admin")) {
|
if (!pathname.startsWith("/admin")) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 管理端会话:Bearer Token + 登录接口返回的 {@link AdminProfile}。
|
* 管理端会话:HttpOnly Cookie 登录态 + 登录接口返回的 {@link AdminProfile}。
|
||||||
*
|
*
|
||||||
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
||||||
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
isAdminAuthRejected,
|
isAdminAuthRejected,
|
||||||
} from "@/lib/admin-auth-reject";
|
} from "@/lib/admin-auth-reject";
|
||||||
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
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";
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
|
|
||||||
export type AdminSessionState = {
|
export type AdminSessionState = {
|
||||||
@@ -25,7 +25,7 @@ export type AdminSessionState = {
|
|||||||
setBearerToken: (token: string | null) => void;
|
setBearerToken: (token: string | null) => void;
|
||||||
setAdminProfile: (profile: AdminProfile | null) => void;
|
setAdminProfile: (profile: AdminProfile | null) => void;
|
||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
/** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */
|
/** 从 HttpOnly Cookie 会话恢复管理员摘要(仅客户端) */
|
||||||
rehydrate: () => void;
|
rehydrate: () => void;
|
||||||
refreshAdminProfile: () => Promise<void>;
|
refreshAdminProfile: () => Promise<void>;
|
||||||
/** @deprecated 使用 {@link clearSession} */
|
/** @deprecated 使用 {@link clearSession} */
|
||||||
@@ -70,24 +70,13 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
|||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = readToken();
|
|
||||||
const profile = readProfile();
|
const profile = readProfile();
|
||||||
if (token) {
|
setAdminBearerToken(null);
|
||||||
setAdminBearerToken(token);
|
set({ bearerToken: "__cookie__", adminProfile: profile });
|
||||||
set({ bearerToken: token, adminProfile: profile });
|
void get().refreshAdminProfile();
|
||||||
void get().refreshAdminProfile();
|
|
||||||
} else {
|
|
||||||
setAdminBearerToken(null);
|
|
||||||
set({ bearerToken: null, adminProfile: null });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshAdminProfile: async () => {
|
refreshAdminProfile: async () => {
|
||||||
const token = get().bearerToken ?? readToken();
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchAdminMeDeduped();
|
const result = await fetchAdminMeDeduped();
|
||||||
writeProfile(result.admin);
|
writeProfile(result.admin);
|
||||||
|
|||||||
@@ -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 {
|
export function readToken(): string | null {
|
||||||
if (typeof window === "undefined") {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromStorage = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim();
|
|
||||||
if (fromStorage) {
|
|
||||||
return fromStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
return readAdminTokenFromDocumentCookie();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeToken(t: string | null): void {
|
export function writeToken(t: string | null): void {
|
||||||
if (typeof window === "undefined") {
|
void t;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type AdminAccountKind =
|
|||||||
| "agent_operator"
|
| "agent_operator"
|
||||||
| "platform_account";
|
| "platform_account";
|
||||||
|
|
||||||
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
/** 登录成功后缓存于会话的管理员摘要 */
|
||||||
export type AdminProfile = {
|
export type AdminProfile = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -52,8 +52,8 @@ export type AdminProfile = {
|
|||||||
|
|
||||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||||
export type AdminAuthLoginResponse = {
|
export type AdminAuthLoginResponse = {
|
||||||
token: string;
|
token?: string;
|
||||||
token_type: string;
|
token_type?: string;
|
||||||
admin: AdminProfile;
|
admin: AdminProfile;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user