diff --git a/src/app/api/auth/marker/route.ts b/src/app/api/auth/marker/route.ts new file mode 100644 index 00000000..ce9abee5 --- /dev/null +++ b/src/app/api/auth/marker/route.ts @@ -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.logout,client 端 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; +} diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx index 2dd31ad1..4649f4a9 100644 --- a/src/app/auth/components/auth-screen.tsx +++ b/src/app/auth/components/auth-screen.tsx @@ -13,10 +13,9 @@ import { useEffect, useRef } from "react"; 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 { useUserDispatch } from "@/stores/user/user-context"; -import { useAuthGate } from "@/lib/auth/nextauth"; import { ROUTES } from "@/router/routes"; import { MobileShell } from "@/app/_components/core/mobile-shell"; import { AuthPanel } from "@/app/auth/components/auth-panel"; @@ -29,19 +28,17 @@ export interface AuthScreenProps { export function AuthScreen({ background = "var(--color-sidebar-background)", }: AuthScreenProps) { + // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== + // 本组件只负责: + // 1. 渲染登录面板 + // 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作) + const router = useRouter(); - const { isAuthed } = useAuthGate(); const state = useAuthState(); - const authDispatch = useAuthDispatch(); const chatDispatch = useChatDispatch(); const userDispatch = useUserDispatch(); const wasSuccess = useRef(false); - // 已登录 → 自动跳 /chat - useEffect(() => { - if (isAuthed) router.replace(ROUTES.chat); - }, [isAuthed, router]); - // 邮箱登录成功 → 通知 Chat + User + 跳转 useEffect(() => { if (state.isSuccess && !wasSuccess.current) { diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 314bf3cf..39d84c8b 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -17,10 +17,10 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; 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 { 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 { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import styles from "./splash-button.module.css"; @@ -31,10 +31,13 @@ export interface SplashButtonProps { } export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) { - // ===== 鉴权状态 ===== - const { isAuthed } = useAuthGate(); + // ===== 鉴权路由跳转已由 src/router/proxy.ts 集中处理 ===== + // 本组件只负责: + // 1. 触发登录动作 + // 2. 登录成功后初始化 chat/user store + navigate 到 /chat(业务层动作, + // 不是鉴权判断;proxy 会让通过因为 cookie 已设) + const state = useAuthState(); - const authDispatch = useAuthDispatch(); const isLoading = state.isLoading; // ===== 跨 store 初始化(登录成功后触发) ===== @@ -43,11 +46,6 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) { const router = useRouter(); const wasSuccess = useRef(false); - // 已登录时跳 /chat - useEffect(() => { - if (isAuthed) router.replace(ROUTES.chat); - }, [isAuthed, router]); - // 邮箱登录成功跳 /chat + 通知 chat/user 初始化 useEffect(() => { if (state.isSuccess && !wasSuccess.current) { diff --git a/src/lib/auth/nextauth-helpers.ts b/src/lib/auth/nextauth-helpers.ts index 8c3952ab..90467d1f 100644 --- a/src/lib/auth/nextauth-helpers.ts +++ b/src/lib/auth/nextauth-helpers.ts @@ -52,6 +52,13 @@ export async function logout(): Promise> { // 清理业务层 token await SpAsyncUtil.remove(StorageKeys.loginToken); await SpAsyncUtil.remove(StorageKeys.refreshToken); + // 同步清路由信号 cookie(client 端走 marker API,让 server 端设 Max-Age=0) + // fail-soft:fetch 失败不影响登出主流程(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); } @@ -124,9 +131,39 @@ interface SocialSession { 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 { + 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 可访问的临时存储。 * jwt callback 中会从这里读取。 + * + * 同时设路由信号 cookie(`cozsweet_authed`)供 `src/router/proxy.ts` 读取。 + * cookie 写失败仅记录 warn,不阻断主流程(业务 token 已落 unstorage,下一次 + * 请求仍可走业务自检)。 */ async function persistBackendSession(data: { token: string; @@ -139,6 +176,12 @@ async function persistBackendSession(data: { } // userId 写入 unstorage(其他业务模块通过 useUserState 读) await SpAsyncUtil.setString(StorageKeys.userId, data.user.id); + // 路由信号 cookie(server 端 next/headers 写) + try { + await setAuthCookieServerSide(); + } catch (e) { + console.warn("[nextauth-helpers] failed to set auth cookie", e); + } } export async function getSocialSession(): Promise { diff --git a/src/router/proxy.ts b/src/router/proxy.ts index c968f268..14da2b36 100644 --- a/src/router/proxy.ts +++ b/src/router/proxy.ts @@ -1,16 +1,18 @@ /** * Next.js 16 Proxy(原 `middleware.ts`) * - * 行为: - * - 当请求携带 NextAuth `next-auth.session-token` cookie 且命中 `/splash` 或 `/auth` - * 时,308 重定向到 `/chat`。 - * - 其他情况透传(`NextResponse.next()`)。 + * 行为(鉴权路由统一在此处理,业务层组件不再做 auth-driven 跳转): + * - 已登录(`cozsweet_authed` cookie 存在)访问 `/splash` 或 `/auth` + * → 308 重定向到 `/chat` + * - 未登录(cookie 缺失)访问 `/chat` 或 `/sidebar` + * → 308 重定向到 `/splash`(首界面) + * - 其他情况透传(`NextResponse.next()`) * * 重要约束: - * - **不**对 `/chat` 做反向重定向:游客态是合法用户。 - * - NextAuth 的 session cookie 由 `src/lib/auth/config.ts` 集中管理;本 proxy 仅做 - * CDN 友好的"乐观"快路径。 - * - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()`)。 + * - **不**检查 NextAuth 自带的 `next-auth.session-token`:统一用自定义 + * `cozsweet_authed` 信号,社交 / 邮箱登录出口都同步设/清这个 cookie。 + * - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()`),用于 UI 状态。 + * - 路由跳转(**唯一**鉴权跳转点)由本 proxy 集中处理。 * * Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`): * - 文件从 `middleware.ts` 重命名为 `proxy.ts`;`middleware` 仍可用但已 deprecated。 @@ -25,36 +27,43 @@ import type { NextRequest } from "next/server"; import { AUTH_ONLY_ROUTES, + PROTECTED_ROUTES, ROUTES, - type StaticRoute, } from "@/router/routes"; -const SESSION_COOKIE_NAMES = [ - "next-auth.session-token", - "__Secure-next-auth.session-token", -]; +/** 路由信号 cookie 名(由 `src/app/api/auth/marker/route.ts` 写) */ +const AUTH_COOKIE = "cozsweet_authed"; -function isAuthOnlyRoute(pathname: string): pathname is StaticRoute { +function isAuthOnlyRoute(pathname: string): boolean { return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname); } +function isProtectedRoute(pathname: string): boolean { + return (PROTECTED_ROUTES as readonly string[]).includes(pathname); +} + /** * Proxy 入口 */ export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; - // 检查 NextAuth session cookie 存在性(cookie value 存在即认为已登录) - const hasSession = SESSION_COOKIE_NAMES.some( - (name) => Boolean(request.cookies.get(name)?.value), - ); + const hasAuth = Boolean(request.cookies.get(AUTH_COOKIE)?.value); - if (hasSession && isAuthOnlyRoute(pathname)) { + // 已登录访问未登录专属页 → /chat + if (hasAuth && isAuthOnlyRoute(pathname)) { const url = request.nextUrl.clone(); url.pathname = ROUTES.chat; 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(); } diff --git a/src/router/routes.ts b/src/router/routes.ts index 34f20772..9eb605d2 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -33,6 +33,12 @@ export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [ ROUTES.auth, ] as const; +/** 仅登录态可见的路由(命中后未登录用户应跳走) */ +export const PROTECTED_ROUTES: readonly StaticRoute[] = [ + ROUTES.chat, + ROUTES.sidebar, +] as const; + /** 公开路由(游客态也允许访问) */ export const PUBLIC_ROUTES: readonly StaticRoute[] = [ ROUTES.chat, diff --git a/src/stores/auth/auth-machine.ts b/src/stores/auth/auth-machine.ts index a9f7ef42..9c0ff21c 100644 --- a/src/stores/auth/auth-machine.ts +++ b/src/stores/auth/auth-machine.ts @@ -79,11 +79,24 @@ async function readGuestId(): Promise { // ============================================================ // Actors(异步服务) // ============================================================ +/** + * 设路由信号 cookie(client 端走 marker API)。 + * fail-soft:失败仅 warn,不阻断登录主流程(业务 token 已落 localStorage)。 + */ +async function setAuthCookieClientSide(): Promise { + try { + await fetch("/api/auth/marker", { method: "POST" }); + } catch (e) { + console.warn("[auth-machine] failed to set auth cookie", e); + } +} + const emailLoginActor = fromPromise( async ({ input }) => { const guestId = await readGuestId(); const result = await authRepository.emailLogin({ ...input, guestId }); if (Result.isErr(result)) throw result.error; + await setAuthCookieClientSide(); return "email" as LoginType; }, ); @@ -104,6 +117,7 @@ const emailRegisterThenLoginActor = fromPromise< guestId, }); if (Result.isErr(loginResult)) throw loginResult.error; + await setAuthCookieClientSide(); return "email" as LoginType; });