refactor(auth): centralize auth routing via proxy and marker cookie

This commit is contained in:
2026-06-10 12:47:47 +08:00
parent 1db3e3ae31
commit 2066934094
7 changed files with 156 additions and 38 deletions
+51
View File
@@ -0,0 +1,51 @@
/**
* 路由信号 cookie 管理端点
*
* 用途:写/清 `cozsweet_authed` httpOnly cookie,作为 `src/router/proxy.ts`
* 的"用户已登录"信号源。
*
* 与 `next-auth.session-token` 的区别:
* - NextAuth cookie 仅社交登录设置,邮箱登录不写入
* - 本端点由所有登录出口(社交 / 邮箱)统一调用,proxy 只需读这一个 cookie
*
* 调用方:
* - 社交登录出口(nextauth-helpers.persistBackendSession**server 端**直接用
* `next/headers.cookies()` 写,不走本端点)
* - 邮箱登录出口(auth-machine.emailLoginActor / emailRegisterThenLoginActor
* **client 端** fetch POST
* - 登出(nextauth-helpers.logoutclient 端 fetch DELETE
*
* 安全:cookie 设为 `HttpOnly` + `SameSite=Lax`prod 额外 `Secure`。
*/
import { NextResponse } from "next/server";
const COOKIE_NAME = "cozsweet_authed";
const ONE_WEEK = 60 * 60 * 24 * 7;
/** 设置 httpOnly 路由信号 cookie(任何登录成功后调用) */
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: COOKIE_NAME,
value: "1",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: ONE_WEEK,
});
return res;
}
/** 清除路由信号 cookie(登出时调用) */
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set({
name: COOKIE_NAME,
value: "",
path: "/",
maxAge: 0,
});
return res;
}
+6 -9
View File
@@ -13,10 +13,9 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { useAuthGate } from "@/lib/auth/nextauth";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core/mobile-shell"; import { MobileShell } from "@/app/_components/core/mobile-shell";
import { AuthPanel } from "@/app/auth/components/auth-panel"; import { AuthPanel } from "@/app/auth/components/auth-panel";
@@ -29,19 +28,17 @@ export interface AuthScreenProps {
export function AuthScreen({ export function AuthScreen({
background = "var(--color-sidebar-background)", background = "var(--color-sidebar-background)",
}: AuthScreenProps) { }: AuthScreenProps) {
// ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
// 本组件只负责:
// 1. 渲染登录面板
// 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作)
const router = useRouter(); const router = useRouter();
const { isAuthed } = useAuthGate();
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const wasSuccess = useRef(false); const wasSuccess = useRef(false);
// 已登录 → 自动跳 /chat
useEffect(() => {
if (isAuthed) router.replace(ROUTES.chat);
}, [isAuthed, router]);
// 邮箱登录成功 → 通知 Chat + User + 跳转 // 邮箱登录成功 → 通知 Chat + User + 跳转
useEffect(() => { useEffect(() => {
if (state.isSuccess && !wasSuccess.current) { if (state.isSuccess && !wasSuccess.current) {
+8 -10
View File
@@ -17,10 +17,10 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useAuthState, useAuthDispatch } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { facebookLogin, useAuthGate } from "@/lib/auth/nextauth"; import { facebookLogin } from "@/lib/auth/nextauth";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
import styles from "./splash-button.module.css"; import styles from "./splash-button.module.css";
@@ -31,10 +31,13 @@ export interface SplashButtonProps {
} }
export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) { export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
// ===== 鉴权状态 ===== // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 =====
const { isAuthed } = useAuthGate(); // 本组件只负责:
// 1. 触发登录动作
// 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作,
// 不是鉴权判断;proxy 会让通过因为 cookie 已设)
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const isLoading = state.isLoading; const isLoading = state.isLoading;
// ===== 跨 store 初始化(登录成功后触发) ===== // ===== 跨 store 初始化(登录成功后触发) =====
@@ -43,11 +46,6 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
const router = useRouter(); const router = useRouter();
const wasSuccess = useRef(false); const wasSuccess = useRef(false);
// 已登录时跳 /chat
useEffect(() => {
if (isAuthed) router.replace(ROUTES.chat);
}, [isAuthed, router]);
// 邮箱登录成功跳 /chat + 通知 chat/user 初始化 // 邮箱登录成功跳 /chat + 通知 chat/user 初始化
useEffect(() => { useEffect(() => {
if (state.isSuccess && !wasSuccess.current) { if (state.isSuccess && !wasSuccess.current) {
+43
View File
@@ -52,6 +52,13 @@ export async function logout(): Promise<Result<void>> {
// 清理业务层 token // 清理业务层 token
await SpAsyncUtil.remove(StorageKeys.loginToken); await SpAsyncUtil.remove(StorageKeys.loginToken);
await SpAsyncUtil.remove(StorageKeys.refreshToken); await SpAsyncUtil.remove(StorageKeys.refreshToken);
// 同步清路由信号 cookieclient 端走 marker API,让 server 端设 Max-Age=0
// fail-softfetch 失败不影响登出主流程(localStorage 已清,业务不可用)
try {
await fetch("/api/auth/marker", { method: "DELETE" });
} catch (e) {
console.warn("[nextauth-helpers] failed to clear auth cookie", e);
}
return Result.ok(undefined); return Result.ok(undefined);
} }
@@ -124,9 +131,39 @@ interface SocialSession {
userId: string; userId: string;
} }
/** 路由信号 cookie 配置(与 `src/app/api/auth/marker/route.ts` 保持一致) */
const AUTH_COOKIE_NAME = "cozsweet_authed";
const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
/**
* server 端设路由信号 cookie(由 signIn callback 调用)
*
* 必须在 server 上下文运行(使用 `next/headers` 的 `cookies()`)。
* 本函数被 `persistBackendSession` 调用,后者仅在 NextAuth signIn callback
* 内被触发,那是 server 上下文。
*
* dynamic import next/headers 避免 client bundle 误报。
*/
async function setAuthCookieServerSide(): Promise<void> {
const { cookies } = await import("next/headers");
(await cookies()).set({
name: AUTH_COOKIE_NAME,
value: "1",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: AUTH_COOKIE_MAX_AGE,
});
}
/** /**
* 把后端返回的 LoginResponse 写入 NextAuth 可访问的临时存储。 * 把后端返回的 LoginResponse 写入 NextAuth 可访问的临时存储。
* jwt callback 中会从这里读取。 * jwt callback 中会从这里读取。
*
* 同时设路由信号 cookie`cozsweet_authed`)供 `src/router/proxy.ts` 读取。
* cookie 写失败仅记录 warn,不阻断主流程(业务 token 已落 unstorage,下一次
* 请求仍可走业务自检)。
*/ */
async function persistBackendSession(data: { async function persistBackendSession(data: {
token: string; token: string;
@@ -139,6 +176,12 @@ async function persistBackendSession(data: {
} }
// userId 写入 unstorage(其他业务模块通过 useUserState 读) // userId 写入 unstorage(其他业务模块通过 useUserState 读)
await SpAsyncUtil.setString(StorageKeys.userId, data.user.id); await SpAsyncUtil.setString(StorageKeys.userId, data.user.id);
// 路由信号 cookieserver 端 next/headers 写)
try {
await setAuthCookieServerSide();
} catch (e) {
console.warn("[nextauth-helpers] failed to set auth cookie", e);
}
} }
export async function getSocialSession(): Promise<SocialSession | null> { export async function getSocialSession(): Promise<SocialSession | null> {
+28 -19
View File
@@ -1,16 +1,18 @@
/** /**
* Next.js 16 Proxy(原 `middleware.ts` * Next.js 16 Proxy(原 `middleware.ts`
* *
* 行为: * 行为(鉴权路由统一在此处理,业务层组件不再做 auth-driven 跳转)
* - 当请求携带 NextAuth `next-auth.session-token` cookie 且命中 `/splash` 或 `/auth` * - 已登录(`cozsweet_authed` cookie 存在)访问 `/splash` 或 `/auth`
* 时,308 重定向到 `/chat` * 308 重定向到 `/chat`
* - 其他情况透传(`NextResponse.next()`)。 * - 未登录(cookie 缺失)访问 `/chat` 或 `/sidebar`
* → 308 重定向到 `/splash`(首界面)
* - 其他情况透传(`NextResponse.next()`
* *
* 重要约束: * 重要约束:
* - **不**对 `/chat` 做反向重定向:游客态是合法用户。 * - **不**检查 NextAuth 自带的 `next-auth.session-token`:统一用自定义
* - NextAuth 的 session cookie 由 `src/lib/auth/config.ts` 集中管理;本 proxy 仅做 * `cozsweet_authed` 信号,社交 / 邮箱登录出口都同步设/清这个 cookie。
* CDN 友好的"乐观"快路径 * - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()`),用于 UI 状态
* - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()` * - 路由跳转(**唯一**鉴权跳转点)由本 proxy 集中处理
* *
* Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`): * Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
* - 文件从 `middleware.ts` 重命名为 `proxy.ts``middleware` 仍可用但已 deprecated。 * - 文件从 `middleware.ts` 重命名为 `proxy.ts``middleware` 仍可用但已 deprecated。
@@ -25,36 +27,43 @@ import type { NextRequest } from "next/server";
import { import {
AUTH_ONLY_ROUTES, AUTH_ONLY_ROUTES,
PROTECTED_ROUTES,
ROUTES, ROUTES,
type StaticRoute,
} from "@/router/routes"; } from "@/router/routes";
const SESSION_COOKIE_NAMES = [ /** 路由信号 cookie 名(由 `src/app/api/auth/marker/route.ts` 写) */
"next-auth.session-token", const AUTH_COOKIE = "cozsweet_authed";
"__Secure-next-auth.session-token",
];
function isAuthOnlyRoute(pathname: string): pathname is StaticRoute { function isAuthOnlyRoute(pathname: string): boolean {
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname); return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
} }
function isProtectedRoute(pathname: string): boolean {
return (PROTECTED_ROUTES as readonly string[]).includes(pathname);
}
/** /**
* Proxy 入口 * Proxy 入口
*/ */
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// 检查 NextAuth session cookie 存在性(cookie value 存在即认为已登录) const hasAuth = Boolean(request.cookies.get(AUTH_COOKIE)?.value);
const hasSession = SESSION_COOKIE_NAMES.some(
(name) => Boolean(request.cookies.get(name)?.value),
);
if (hasSession && isAuthOnlyRoute(pathname)) { // 已登录访问未登录专属页 → /chat
if (hasAuth && isAuthOnlyRoute(pathname)) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = ROUTES.chat; url.pathname = ROUTES.chat;
return NextResponse.redirect(url, 308); return NextResponse.redirect(url, 308);
} }
// 未登录访问登录态专属页 → /splash(首界面)
if (!hasAuth && isProtectedRoute(pathname)) {
const url = request.nextUrl.clone();
url.pathname = ROUTES.splash;
return NextResponse.redirect(url, 308);
}
return NextResponse.next(); return NextResponse.next();
} }
+6
View File
@@ -33,6 +33,12 @@ export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
ROUTES.auth, ROUTES.auth,
] as const; ] as const;
/** 仅登录态可见的路由(命中后未登录用户应跳走) */
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
ROUTES.chat,
ROUTES.sidebar,
] as const;
/** 公开路由(游客态也允许访问) */ /** 公开路由(游客态也允许访问) */
export const PUBLIC_ROUTES: readonly StaticRoute[] = [ export const PUBLIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat, ROUTES.chat,
+14
View File
@@ -79,11 +79,24 @@ async function readGuestId(): Promise<string | undefined> {
// ============================================================ // ============================================================
// Actors(异步服务) // Actors(异步服务)
// ============================================================ // ============================================================
/**
* 设路由信号 cookieclient 端走 marker API)。
* fail-soft:失败仅 warn,不阻断登录主流程(业务 token 已落 localStorage)。
*/
async function setAuthCookieClientSide(): Promise<void> {
try {
await fetch("/api/auth/marker", { method: "POST" });
} catch (e) {
console.warn("[auth-machine] failed to set auth cookie", e);
}
}
const emailLoginActor = fromPromise<LoginType, { email: string; password: string }>( const emailLoginActor = fromPromise<LoginType, { email: string; password: string }>(
async ({ input }) => { async ({ input }) => {
const guestId = await readGuestId(); const guestId = await readGuestId();
const result = await authRepository.emailLogin({ ...input, guestId }); const result = await authRepository.emailLogin({ ...input, guestId });
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
await setAuthCookieClientSide();
return "email" as LoginType; return "email" as LoginType;
}, },
); );
@@ -104,6 +117,7 @@ const emailRegisterThenLoginActor = fromPromise<
guestId, guestId,
}); });
if (Result.isErr(loginResult)) throw loginResult.error; if (Result.isErr(loginResult)) throw loginResult.error;
await setAuthCookieClientSide();
return "email" as LoginType; return "email" as LoginType;
}); });