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:
2026-06-10 15:34:52 +08:00
parent 90bb711a45
commit d70e61f92e
115 changed files with 9004 additions and 493 deletions
+28
View File
@@ -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) };
}
}
-129
View File
@@ -1,129 +0,0 @@
/**
* NextAuth 配置
*
* 集中管理 OAuth providersGoogle + 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;
}
}
+20
View File
@@ -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");
}
}
+20
View File
@@ -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");
}
}
+15
View File
@@ -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();
}
}
-224
View File
@@ -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);
// 同步清路由信号 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);
}
// ============================================================
// 内部: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") {
// GoogleNextAuth 拿到的 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") {
// FacebookaccessToken 来自 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);
// 路由信号 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> {
// 从 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
View File
@@ -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
View File
@@ -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";