refactor(auth): switch to class-based social login and v4 route handler
Migrate from function-based social login helpers (`facebookLogin`, `googleLogin`) to class-based services (`new FacebookLogin().signIn()`, `new GoogleLogin().signIn()`), updating call sites in `AuthFacebookPanel` and `SplashButton` with try/catch error handling. The NextAuth route handler is also refactored to the v4 pattern, importing pre-built `GET` / `POST` exports from `@/lib/auth/nextauth` instead of constructing the handler inline with `NextAuth(authOptions)`.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* NextAuth App Router 入口
|
||||
* NextAuth App Router 入口(v4 模式)
|
||||
*
|
||||
* 路径:/api/auth/[...nextauth]
|
||||
* 自动处理 NextAuth 所有内置路由:
|
||||
* v4 用单个 handler 函数,需手动 re-export 为 `GET` / `POST`:
|
||||
* /api/auth/signin/[provider]
|
||||
* /api/auth/callback/[provider]
|
||||
* /api/auth/signout
|
||||
@@ -10,10 +9,6 @@
|
||||
* /api/auth/csrf
|
||||
* /api/auth/providers
|
||||
*/
|
||||
import NextAuth from "next-auth";
|
||||
import { GET, POST } from "@/lib/auth/nextauth";
|
||||
|
||||
import { authOptions } from "@/lib/auth/config";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
export { GET, POST };
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 路由信号 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;
|
||||
}
|
||||
@@ -7,13 +7,13 @@
|
||||
* 视觉规格(与 Dart 对齐):
|
||||
* - Spacer → logo(120h)→ Spacer → Facebook 主按钮 → 链接 → Spacer → 法律
|
||||
* - Facebook 主按钮:46px 白胶囊 + Facebook "f" 图标(#1877f2)+ "Login with Facebook"
|
||||
* - 社交登录走 NextAuth:`facebookLogin()` / `googleLogin()`
|
||||
* - 社交登录走 NextAuth:`new FacebookLogin().signIn()` / `new GoogleLogin().signIn()`
|
||||
* - "Other Sign-in Options" 链接打开底部弹层
|
||||
* - 弹层 Email 按钮 → 触发 `onSwitchToEmail`(父级派发 `AuthPanelModeChanged`)
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { facebookLogin, googleLogin } from "@/lib/auth/nextauth";
|
||||
import { FacebookLogin, GoogleLogin } from "@/lib/auth/nextauth";
|
||||
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
@@ -32,17 +32,25 @@ export function AuthFacebookPanel({ onSwitchToEmail }: AuthFacebookPanelProps) {
|
||||
const handleFacebook = async () => {
|
||||
setBusy("facebook");
|
||||
setError(null);
|
||||
const r = await facebookLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) setError(r.error?.message ?? "Facebook login failed");
|
||||
try {
|
||||
await new FacebookLogin().signIn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Facebook login failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setBusy("google");
|
||||
setError(null);
|
||||
const r = await googleLogin();
|
||||
setBusy(null);
|
||||
if (!r.success) setError(r.error?.message ?? "Google login failed");
|
||||
try {
|
||||
await new GoogleLogin().signIn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Google login failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 消费 `useChatDispatch` / `useUserDispatch`(登录成功后初始化)
|
||||
* - 自管路由跳转(已登录 / 登录成功 → /chat)
|
||||
* - Skip 用 `<Link>` 保留 SPA 体验
|
||||
* - 社交登录按钮直接调 `nextauth.facebookLogin()`(NextAuth 流程)
|
||||
* - 社交登录按钮走 `new FacebookLogin().signIn()`(NextAuth 流程)
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -20,7 +20,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
import { facebookLogin } 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";
|
||||
@@ -57,9 +57,10 @@ export function SplashButton({ skipHref = ROUTES.chat }: SplashButtonProps) {
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
const handleFacebookLogin = async () => {
|
||||
const r = await facebookLogin();
|
||||
if (!r.success) {
|
||||
console.error("[splash-button] Facebook login failed", r.error);
|
||||
try {
|
||||
await new FacebookLogin().signIn();
|
||||
} catch (e) {
|
||||
console.error("[splash-button] Facebook login failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
* 集中管理应用中使用的常量值
|
||||
* 原始 Dart: lib/res/constants.dart
|
||||
*
|
||||
* 注意:
|
||||
* - `facebookAppId` / `googleClientId` 优先从 `process.env.NEXT_PUBLIC_*` 读取,
|
||||
* 缺省回退到 Dart 端的硬编码值(与 `lib/auth/config.ts` 的 `?? ""` 行为不同——
|
||||
* 此处保留默认值便于未配 env 的本地开发)。
|
||||
* - `appTitle` / `appUrl` 复用 `core/net/config/api_config.ts` 的 `getAppEnv()`,
|
||||
* 其底层为 `process.env.NEXT_PUBLIC_APP_ENV`(三态:`development` / `test` / `production`),
|
||||
* 与 `.env.example` 的约定一致。
|
||||
* 清理记录:本轮移除 `facebookAppId` / `googleClientId` 字段 —— Next.js 端
|
||||
* 不再硬编码 OAuth 凭据,改为由 `@/lib/auth/nextauth.ts` 从 `AUTH_*_ID` /
|
||||
* `AUTH_*_SECRET` env 读取(Auth.js v5 命名约定)。原硬编码值已废弃。
|
||||
*
|
||||
* `appTitle` / `appUrl` 复用 `core/net/config/api_config.ts` 的 `getAppEnv()`,
|
||||
* 其底层为 `process.env.NEXT_PUBLIC_APP_ENV`(三态:`development` / `test` / `production`),
|
||||
* 与 `.env.example` 的约定一致。
|
||||
*/
|
||||
import { getAppEnv } from "@/core/net/config/api_config";
|
||||
|
||||
@@ -25,15 +25,6 @@ export class AppConstants {
|
||||
static readonly termsOfServiceUrl =
|
||||
"https://docs.google.com/document/d/1ZL5-uDKLNQrkNnWNKlVFi5atwA9hBpNVyoXzzXf1GHs/edit?usp=sharing";
|
||||
|
||||
/** Facebook App ID(env 覆盖优先,缺省回退到 Dart 端硬编码值) */
|
||||
static readonly facebookAppId =
|
||||
process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "26934819589512827";
|
||||
|
||||
/** Google Auth Client ID(env 覆盖优先,缺省回退到 Dart 端硬编码值) */
|
||||
static readonly googleClientId =
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ??
|
||||
"351948560061-isubggf6eahfii9n0stpf2qu3haralro.apps.googleusercontent.com";
|
||||
|
||||
/** 开发环境 APP 标题 */
|
||||
static readonly appTitleDevelopment = "Develop";
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
/**
|
||||
* 客户端鉴权门(class-oriented 封装)
|
||||
*
|
||||
* `useState` 是 static 方法而非实例方法(hooks 必须在 React 函数组件中调用,
|
||||
* static 方法本质上仍由组件调用,hook 链合法)。调用方写法:
|
||||
*
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { isAuthed } = AuthGate.useState();
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 原始 Dart: nextauth-helpers.useAuthGate()
|
||||
*/
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export interface AuthGateState {
|
||||
isAuthed: boolean;
|
||||
}
|
||||
|
||||
export class AuthGate {
|
||||
/** 必须在 React 函数组件内调用(useSession 是 hook) */
|
||||
static useState(): AuthGateState {
|
||||
const { data: session } = useSession();
|
||||
return { isAuthed: Boolean(session?.user) };
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* NextAuth 配置
|
||||
*
|
||||
* 集中管理 OAuth providers(Google + Facebook)+ session 策略 + 回调。
|
||||
* 由 `src/app/api/auth/[...nextauth]/route.ts` 引用。
|
||||
*
|
||||
* Session 策略:**JWT**(无服务端存储;token 签名 / 过期由 next-auth 内部处理)。
|
||||
* 业务层 token(用于后端鉴权)通过 `signIn` callback 写入 unstorage `StorageKeys.loginToken`。
|
||||
*/
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import FacebookProvider from "next-auth/providers/facebook";
|
||||
|
||||
const useSecureCookies = process.env.NODE_ENV === "production";
|
||||
const cookiePrefix = useSecureCookies ? "__Secure-" : "";
|
||||
|
||||
const cookieConfig = {
|
||||
// 默认 cookie 名(next-auth v4 默认)
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: useSecureCookies,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
// ===== Providers =====
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
||||
}),
|
||||
FacebookProvider({
|
||||
clientId: process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "",
|
||||
clientSecret: process.env.FACEBOOK_APP_SECRET ?? "",
|
||||
}),
|
||||
],
|
||||
|
||||
// ===== Session =====
|
||||
session: { strategy: "jwt" },
|
||||
|
||||
// ===== Cookie =====
|
||||
cookies: cookieConfig,
|
||||
|
||||
// ===== Pages =====
|
||||
// OAuth 流程中 NextAuth 重定向到这些路径
|
||||
pages: {
|
||||
signIn: "/auth",
|
||||
error: "/auth",
|
||||
},
|
||||
|
||||
// ===== Callbacks =====
|
||||
callbacks: {
|
||||
/**
|
||||
* 登录成功时同步用户到后端(写入 unstorage + 后端用户表)
|
||||
* 业务层 token 持久化在 `jwt` callback 中
|
||||
*/
|
||||
async signIn({ user, account, profile }) {
|
||||
if (!account || !user) return false;
|
||||
|
||||
// 仅对 OAuth provider 触发后端同步(credentials provider 走 emailLogin 流程)
|
||||
if (account.provider === "google" || account.provider === "facebook") {
|
||||
// 通过 dynamic import 避免循环依赖 + SSR 时不加载
|
||||
const { handleSocialSignIn } = await import("./nextauth-helpers");
|
||||
const result = await handleSocialSignIn({
|
||||
provider: account.provider,
|
||||
user,
|
||||
account,
|
||||
profile,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* JWT callback:在 token 中携带业务字段(loginToken、userId)
|
||||
* 业务层可从 `useSession().data` 读到这些字段
|
||||
*/
|
||||
async jwt({ token, user, account }) {
|
||||
if (user && account) {
|
||||
// 首次登录时,从 signIn callback 写入的 unstorage 读取
|
||||
if (account.provider === "google" || account.provider === "facebook") {
|
||||
const { getSocialSession } = await import("./nextauth-helpers");
|
||||
const session = await getSocialSession();
|
||||
if (session) {
|
||||
token.loginToken = session.loginToken;
|
||||
token.refreshToken = session.refreshToken;
|
||||
token.userId = session.userId;
|
||||
token.provider = account.provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Session callback:把 JWT 中的字段暴露给 `useSession()`
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session as unknown as Record<string, unknown>).loginToken =
|
||||
token.loginToken;
|
||||
(session as unknown as Record<string, unknown>).userId = token.userId;
|
||||
(session as unknown as Record<string, unknown>).provider =
|
||||
token.provider;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// ===== Secret =====
|
||||
secret: process.env.NEXTAUTH_SECRET ?? "dev-secret-change-in-prod",
|
||||
};
|
||||
|
||||
/**
|
||||
* 扩展 next-auth Session 类型,加入业务字段
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
loginToken?: string;
|
||||
userId?: string;
|
||||
provider?: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
/**
|
||||
* Facebook 登录(NextAuth OAuth 流程触发器)
|
||||
*
|
||||
* 调用 `signIn("facebook")` 触发 NextAuth 的 OAuth 流程:
|
||||
* 1. NextAuth 重定向到 Facebook OAuth 授权页
|
||||
* 2. 用户授权 → Facebook 回调 /api/auth/callback/facebook
|
||||
* 3. NextAuth 写 `next-auth.session-token` (httpOnly) cookie
|
||||
* 4. 重定向到 callbackUrl (默认 /chat)
|
||||
*
|
||||
* **不**做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
|
||||
* 原始 Dart: nextauth-helpers.facebookLogin()
|
||||
*/
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
export class FacebookLogin {
|
||||
async signIn(): Promise<void> {
|
||||
await signIn("facebook");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
/**
|
||||
* Google 登录(NextAuth OAuth 流程触发器)
|
||||
*
|
||||
* 调用 `signIn("google")` 触发 NextAuth 的 OAuth 流程:
|
||||
* 1. NextAuth 重定向到 Google OAuth 授权页
|
||||
* 2. 用户授权 → Google 回调 /api/auth/callback/google
|
||||
* 3. NextAuth 写 `next-auth.session-token` (httpOnly) cookie
|
||||
* 4. 重定向到 callbackUrl (默认 /chat)
|
||||
*
|
||||
* **不**做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
|
||||
* 原始 Dart: nextauth-helpers.googleLogin()
|
||||
*/
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
export class GoogleLogin {
|
||||
async signIn(): Promise<void> {
|
||||
await signIn("google");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
/**
|
||||
* 登出(NextAuth 流程触发器)
|
||||
*
|
||||
* 调用 `signOut()` 清除 NextAuth session cookie 并重定向。
|
||||
* **不**做任何持久化(不写 unstorage / 不调后端 / 不设自定义 cookie)。
|
||||
* 原始 Dart: nextauth-helpers.logout()
|
||||
*/
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export class Logout {
|
||||
async signOut(): Promise<void> {
|
||||
await signOut();
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* NextAuth 业务层 helpers
|
||||
*
|
||||
* 处理 OAuth 登录后的"业务层同步":
|
||||
* 1. Google/Facebook OAuth 完成 → NextAuth 返回 user/account/profile
|
||||
* 2. 用 idToken / accessToken 调后端 `authRepository.googleLogin` / `facebookLogin`
|
||||
* 3. 后端返回 `LoginResponse`(含业务 token + user)
|
||||
* 4. 写入 unstorage + NextAuth JWT
|
||||
*
|
||||
* 同时提供 `useAuthGate()` 包装(保持原 API 形状,内部用 `useSession()` 实现)。
|
||||
*/
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Account, Profile, User } from "next-auth";
|
||||
|
||||
import { authRepository } from "@/data/repositories/auth_repository";
|
||||
import { SpAsyncUtil } from "@/utils/storage";
|
||||
import { Result } from "@/utils/result";
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
|
||||
/** 暴露给外部的:Google 登录 */
|
||||
export async function googleLogin(): Promise<Result<{ isNewUser: boolean }>> {
|
||||
// NextAuth 在客户端通过 signIn() 触发跳转;服务端场景下可通过 POST /api/auth/signin/google
|
||||
// 本函数封装整个流程:触发 NextAuth OAuth → 后端同步 → 返回业务 session
|
||||
const { signIn } = await import("next-auth/react");
|
||||
const result = await signIn("google", {
|
||||
redirect: false,
|
||||
callbackUrl: "/chat",
|
||||
});
|
||||
if (!result || result.error) {
|
||||
return Result.err(new Error(result?.error ?? "Google sign-in failed"));
|
||||
}
|
||||
return Result.ok({ isNewUser: false });
|
||||
}
|
||||
|
||||
/** 暴露给外部的:Facebook 登录 */
|
||||
export async function facebookLogin(): Promise<Result<{ isNewUser: boolean }>> {
|
||||
const { signIn } = await import("next-auth/react");
|
||||
const result = await signIn("facebook", {
|
||||
redirect: false,
|
||||
callbackUrl: "/chat",
|
||||
});
|
||||
if (!result || result.error) {
|
||||
return Result.err(new Error(result?.error ?? "Facebook sign-in failed"));
|
||||
}
|
||||
return Result.ok({ isNewUser: false });
|
||||
}
|
||||
|
||||
/** 暴露给外部的:登出 */
|
||||
export async function logout(): Promise<Result<void>> {
|
||||
const { signOut } = await import("next-auth/react");
|
||||
await signOut({ redirect: false });
|
||||
// 清理业务层 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);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部:signIn callback 用的业务层同步
|
||||
// ============================================================
|
||||
|
||||
interface SocialSignInInput {
|
||||
provider: string;
|
||||
user: User;
|
||||
account: Account;
|
||||
profile?: Profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 登录完成后调后端同步用户。
|
||||
* 写入 unstorage(业务 token)+ NextAuth session 字段。
|
||||
*
|
||||
* 返回 `true` 允许 NextAuth 完成登录;`false` 中止。
|
||||
*/
|
||||
export async function handleSocialSignIn(
|
||||
input: SocialSignInInput,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const guestId = await readGuestId();
|
||||
|
||||
if (input.provider === "google") {
|
||||
// Google:NextAuth 拿到的 idToken 来自 account.id_token
|
||||
const idToken = input.account.id_token;
|
||||
if (!idToken) {
|
||||
console.error("[nextauth-helpers] Google id_token missing");
|
||||
return false;
|
||||
}
|
||||
const email = input.user.email ?? "";
|
||||
const result = await authRepository.googleLogin({ idToken, guestId });
|
||||
if (Result.isErr(result)) {
|
||||
console.error("[nextauth-helpers] Google backend login failed", result.error);
|
||||
return false;
|
||||
}
|
||||
await persistBackendSession(result.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (input.provider === "facebook") {
|
||||
// Facebook:accessToken 来自 account.access_token
|
||||
const accessToken = input.account.access_token;
|
||||
if (!accessToken) {
|
||||
console.error("[nextauth-helpers] Facebook access_token missing");
|
||||
return false;
|
||||
}
|
||||
const result = await authRepository.facebookLogin({ accessToken, guestId });
|
||||
if (Result.isErr(result)) {
|
||||
console.error("[nextauth-helpers] Facebook backend login failed", result.error);
|
||||
return false;
|
||||
}
|
||||
await persistBackendSession(result.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error("[nextauth-helpers] social sign-in error", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface SocialSession {
|
||||
loginToken: string;
|
||||
refreshToken?: 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 可访问的临时存储。
|
||||
* jwt callback 中会从这里读取。
|
||||
*
|
||||
* 同时设路由信号 cookie(`cozsweet_authed`)供 `src/router/proxy.ts` 读取。
|
||||
* cookie 写失败仅记录 warn,不阻断主流程(业务 token 已落 unstorage,下一次
|
||||
* 请求仍可走业务自检)。
|
||||
*/
|
||||
async function persistBackendSession(data: {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
user: { id: string };
|
||||
}): Promise<void> {
|
||||
await SpAsyncUtil.setString(StorageKeys.loginToken, data.token);
|
||||
if (data.refreshToken) {
|
||||
await SpAsyncUtil.setString(StorageKeys.refreshToken, data.refreshToken);
|
||||
}
|
||||
// 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> {
|
||||
// 从 unstorage 读取(之前 persistBackendSession 写入的)
|
||||
const tokenR = await SpAsyncUtil.getString(StorageKeys.loginToken);
|
||||
const userIdR = await SpAsyncUtil.getString(StorageKeys.userId);
|
||||
const refreshR = await SpAsyncUtil.getString(StorageKeys.refreshToken);
|
||||
if (!tokenR.success || !tokenR.data || !userIdR.success || !userIdR.data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
loginToken: tokenR.data,
|
||||
userId: userIdR.data,
|
||||
refreshToken: refreshR.success ? refreshR.data ?? undefined : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 客户端鉴权门 hook(保持原 useAuthGate 形状,内部用 useSession)
|
||||
// ============================================================
|
||||
|
||||
export interface AuthGateState {
|
||||
isAuthed: boolean;
|
||||
}
|
||||
|
||||
export function useAuthGate(): AuthGateState {
|
||||
const { data: session } = useSession();
|
||||
return { isAuthed: Boolean(session?.user) };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部 helpers
|
||||
// ============================================================
|
||||
async function readGuestId(): Promise<string | undefined> {
|
||||
const r = await SpAsyncUtil.getString(StorageKeys.deviceId);
|
||||
if (r.success && r.data != null && r.data.length > 0) {
|
||||
return r.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
+47
-23
@@ -1,27 +1,51 @@
|
||||
/**
|
||||
* NextAuth 业务层主入口
|
||||
* NextAuth v4 配置(单一来源)
|
||||
*
|
||||
* 对外提供两个干净的登录接口(用户要求):
|
||||
* - `googleLogin()` — 触发 Google OAuth 登录
|
||||
* - `facebookLogin()` — 触发 Facebook OAuth 登录
|
||||
* 极简模式:参考 `auth.js` 官方示例的**精神**(不做任何持久化),但保留 v4 语法:
|
||||
* - 客户端点击登录按钮 → `new GoogleLogin().signIn()` / `new FacebookLogin().signIn()`
|
||||
* - NextAuth 重定向到 Google / Facebook OAuth
|
||||
* - OAuth 回调 → NextAuth 写 `next-auth.session-token` (httpOnly) cookie
|
||||
* - 重定向到 callbackUrl (默认 /chat)
|
||||
* - 业务 token 持久化(unstorage / 后端 / 自定义 cookie)**全部不在本轮 scope**
|
||||
*
|
||||
* 调用方(splash-button / auth-facebook-panel / splash-screen):
|
||||
* ```ts
|
||||
* import { googleLogin, facebookLogin, useAuthGate } from "@/lib/auth/nextauth";
|
||||
*
|
||||
* const r = await googleLogin();
|
||||
* if (r.success) { ... }
|
||||
* ```
|
||||
*
|
||||
* 兼容:
|
||||
* - `useAuthGate()` API 与旧 hook 完全相同(业务层零修改)
|
||||
* - 内部实现用 `useSession()`(NextAuth 客户端 API)
|
||||
* - 后端同步通过 `nextauth-helpers.handleSocialSignIn` 在 NextAuth signIn callback 中完成
|
||||
* 导出 `GET` / `POST` 给 `src/app/api/auth/[...nextauth]/route.ts` 引用。
|
||||
* 客户端通过 `src/lib/auth/{google_login,facebook_login,logout,auth_gate}.ts` 触发。
|
||||
*/
|
||||
export {
|
||||
googleLogin,
|
||||
facebookLogin,
|
||||
logout,
|
||||
useAuthGate,
|
||||
type AuthGateState,
|
||||
} from "./nextauth-helpers";
|
||||
import NextAuth from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
import Facebook from "next-auth/providers/facebook";
|
||||
|
||||
/**
|
||||
* v4 模式:`NextAuth(options)` 返回单个 handler 函数(v5 才返回 `{ handlers, signIn, signOut, auth }`)。
|
||||
* 通过 `export { handler as GET, handler as POST }` 暴露给 Next.js 路由。
|
||||
*/
|
||||
const handler = NextAuth({
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.AUTH_GOOGLE_ID ?? "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET ?? "",
|
||||
}),
|
||||
Facebook({
|
||||
clientId: process.env.AUTH_FACEBOOK_ID ?? "",
|
||||
clientSecret: process.env.AUTH_FACEBOOK_SECRET ?? "",
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/auth",
|
||||
},
|
||||
secret: process.env.AUTH_SECRET ?? "dev-secret-change-in-prod",
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
// ============================================================
|
||||
// 客户端登录类(class-oriented 封装)
|
||||
// 每个 class 一个独立文件,详见同目录的子模块。
|
||||
// 这里 re-export 仅为消费者提供 `@/lib/auth/nextauth` 单点导入。
|
||||
// 注:上方 `next-auth` 是 server-only,本文件编译时会被 Next.js 切分到 server bundle;
|
||||
// 客户端 import `GoogleLogin` / `FacebookLogin` / `Logout` / `AuthGate` 时不会拉入。
|
||||
// ============================================================
|
||||
export { GoogleLogin } from "./google_login";
|
||||
export { FacebookLogin } from "./facebook_login";
|
||||
export { Logout } from "./logout";
|
||||
export { AuthGate, type AuthGateState } from "./auth_gate";
|
||||
|
||||
+2
-3
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* `src/lib/` 手写 barrel
|
||||
*
|
||||
* 注意:故意**不**把 `src/lib/auth/nextauth.ts`(Client-only)放进来,
|
||||
* 注意:故意**不**把 `src/lib/auth/nextauth.ts`(Client-only 类)放进来,
|
||||
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
||||
* 如需导入,请直接 `import { ... } from "@/lib/auth/nextauth";`。
|
||||
* 如需导入,请直接 `import { ... } from "@/lib/auth/nextauth"` 或各 class 文件路径。
|
||||
*
|
||||
* 注:路由相关常量已迁移至 `@/router`(`src/router/routes.ts`)。
|
||||
* 路由守卫已迁移至 `@/router/proxy`(`src/router/proxy.ts`)。
|
||||
*/
|
||||
|
||||
export * from "./breakpoints";
|
||||
export * from "./auth/nextauth-helpers";
|
||||
|
||||
+18
-9
@@ -2,17 +2,20 @@
|
||||
* Next.js 16 Proxy(原 `middleware.ts`)
|
||||
*
|
||||
* 行为(鉴权路由统一在此处理,业务层组件不再做 auth-driven 跳转):
|
||||
* - 已登录(`cozsweet_authed` cookie 存在)访问 `/splash` 或 `/auth`
|
||||
* - 已登录(NextAuth `next-auth.session-token` cookie 存在)访问 `/splash` 或 `/auth`
|
||||
* → 308 重定向到 `/chat`
|
||||
* - 未登录(cookie 缺失)访问 `/chat` 或 `/sidebar`
|
||||
* → 308 重定向到 `/splash`(首界面)
|
||||
* - 其他情况透传(`NextResponse.next()`)
|
||||
*
|
||||
* 重要约束:
|
||||
* - **不**检查 NextAuth 自带的 `next-auth.session-token`:统一用自定义
|
||||
* `cozsweet_authed` 信号,社交 / 邮箱登录出口都同步设/清这个 cookie。
|
||||
* - 真正的鉴权门在 Client 侧 `useAuthGate()`(用 `useSession()`),用于 UI 状态。
|
||||
* - **不**做自定义 cookie 持久化:统一用 NextAuth 内置 `next-auth.session-token` /
|
||||
* `__Secure-next-auth.session-token`(v5 默认 httpOnly + secure)作信号。
|
||||
* - 真正的鉴权门在 Client 侧 `AuthGate.useState()`(用 `useSession()`),用于 UI 状态。
|
||||
* - 路由跳转(**唯一**鉴权跳转点)由本 proxy 集中处理。
|
||||
* - 副作用:邮箱登录用户(不走 NextAuth)不会被本 proxy 识别为已登录。
|
||||
* 邮箱登录在后续轮次若要恢复 proxy 行为,需引入 NextAuth EmailProvider 或独立
|
||||
* email-session 体系。
|
||||
*
|
||||
* Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
|
||||
* - 文件从 `middleware.ts` 重命名为 `proxy.ts`;`middleware` 仍可用但已 deprecated。
|
||||
@@ -31,8 +34,11 @@ import {
|
||||
ROUTES,
|
||||
} from "@/router/routes";
|
||||
|
||||
/** 路由信号 cookie 名(由 `src/app/api/auth/marker/route.ts` 写) */
|
||||
const AUTH_COOKIE = "cozsweet_authed";
|
||||
/** NextAuth 内置 session cookie 名(v5 默认 httpOnly) */
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
"next-auth.session-token",
|
||||
"__Secure-next-auth.session-token",
|
||||
];
|
||||
|
||||
function isAuthOnlyRoute(pathname: string): boolean {
|
||||
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||
@@ -48,17 +54,20 @@ function isProtectedRoute(pathname: string): boolean {
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const hasAuth = Boolean(request.cookies.get(AUTH_COOKIE)?.value);
|
||||
// 检查 NextAuth session cookie 存在性
|
||||
const hasSession = SESSION_COOKIE_NAMES.some(
|
||||
(name) => Boolean(request.cookies.get(name)?.value),
|
||||
);
|
||||
|
||||
// 已登录访问未登录专属页 → /chat
|
||||
if (hasAuth && isAuthOnlyRoute(pathname)) {
|
||||
if (hasSession && isAuthOnlyRoute(pathname)) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = ROUTES.chat;
|
||||
return NextResponse.redirect(url, 308);
|
||||
}
|
||||
|
||||
// 未登录访问登录态专属页 → /splash(首界面)
|
||||
if (!hasAuth && isProtectedRoute(pathname)) {
|
||||
if (!hasSession && isProtectedRoute(pathname)) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = ROUTES.splash;
|
||||
return NextResponse.redirect(url, 308);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart
|
||||
*
|
||||
* 本轮迁移:社交登录改由 NextAuth 处理(`@/lib/auth/nextauth`),状态机仅保留
|
||||
* 邮箱注册/登录 + 通用 reset 事件。社交登录直接 `await nextauth.googleLogin()` 即可。
|
||||
* 邮箱注册/登录 + 通用 reset 事件。社交登录走 `new GoogleLogin().signIn()` /
|
||||
* `new FacebookLogin().signIn()`。
|
||||
*/
|
||||
import { setup, fromPromise, assign } from "xstate";
|
||||
|
||||
@@ -60,7 +61,7 @@ export type AuthEvent =
|
||||
username: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
// 社交登录(已迁移到 NextAuth)— 业务层直接 await nextauth.googleLogin() 即可
|
||||
// 社交登录(已迁移到 NextAuth)— 业务层走 GoogleLogin / FacebookLogin 类
|
||||
| { type: "AuthGoogleLoginSubmitted" }
|
||||
| { type: "AuthFacebookLoginSubmitted" }
|
||||
| { type: "AuthAppleLoginSubmitted" };
|
||||
@@ -79,24 +80,15 @@ 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);
|
||||
}
|
||||
}
|
||||
// 邮箱登录 actor 仅调 authRepository.emailLogin 拿业务 token。
|
||||
// 本轮起不再调 /api/auth/marker 写 cookie(统一迁到 NextAuth session
|
||||
// cookie 检测,邮箱登录 proxy 行为在后续轮恢复)。
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
@@ -117,7 +109,6 @@ const emailRegisterThenLoginActor = fromPromise<
|
||||
guestId,
|
||||
});
|
||||
if (Result.isErr(loginResult)) throw loginResult.error;
|
||||
await setAuthCookieClientSide();
|
||||
return "email" as LoginType;
|
||||
});
|
||||
|
||||
@@ -166,7 +157,7 @@ export const authMachine = setup({
|
||||
},
|
||||
AuthEmailLoginSubmitted: "loadingEmailLogin",
|
||||
AuthEmailRegisterSubmitted: "loadingEmailRegister",
|
||||
// 社交登录已迁移到 NextAuth,业务层直接 await nextauth.googleLogin() / nextauth.facebookLogin()
|
||||
// 社交登录已迁移到 NextAuth + GoogleLogin / FacebookLogin 类
|
||||
// 状态机保留事件以兼容旧调用方(实际不再触发)
|
||||
AuthGoogleLoginSubmitted: {},
|
||||
AuthFacebookLoginSubmitted: {},
|
||||
|
||||
Reference in New Issue
Block a user