refactor(auth): centralize auth routing via proxy and marker cookie
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -52,6 +52,13 @@ export async function logout(): Promise<Result<void>> {
|
||||
// 清理业务层 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<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 可访问的临时存储。
|
||||
* 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<SocialSession | null> {
|
||||
|
||||
+28
-19
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,11 +79,24 @@ async function readGuestId(): Promise<string | undefined> {
|
||||
// ============================================================
|
||||
// Actors(异步服务)
|
||||
// ============================================================
|
||||
/**
|
||||
* 设路由信号 cookie(client 端走 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 }>(
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user