refactor(auth): migrate social login to next-auth
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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,181 @@
|
||||
/**
|
||||
* 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);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端返回的 LoginResponse 写入 NextAuth 可访问的临时存储。
|
||||
* jwt callback 中会从这里读取。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* NextAuth 业务层主入口
|
||||
*
|
||||
* 对外提供两个干净的登录接口(用户要求):
|
||||
* - `googleLogin()` — 触发 Google OAuth 登录
|
||||
* - `facebookLogin()` — 触发 Facebook OAuth 登录
|
||||
*
|
||||
* 调用方(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 中完成
|
||||
*/
|
||||
export {
|
||||
googleLogin,
|
||||
facebookLogin,
|
||||
logout,
|
||||
useAuthGate,
|
||||
type AuthGateState,
|
||||
} from "./nextauth-helpers";
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 纯函数路由守卫
|
||||
*
|
||||
* 不依赖 React/Next.js,可被 `src/proxy.ts`(边缘运行时)与
|
||||
* `src/lib/auth/use-auth-gate.tsx`(客户端 hook)共用。
|
||||
*
|
||||
* 对齐 Flutter `lib/router/app_router.dart` 中 `_handleAuthRedirect` 的语义:
|
||||
* 已登录用户访问 `/splash` 或 `/auth` 时,重定向到 `/chat`。
|
||||
*/
|
||||
|
||||
import { AUTH_ONLY_ROUTES, ROUTES, type StaticRoute } from "../routes";
|
||||
|
||||
/**
|
||||
* 判定路径是否为"仅未登录态可见"的入口路由。
|
||||
*/
|
||||
export function isAuthOnlyRoute(pathname: string): pathname is StaticRoute {
|
||||
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定路径是否为公开路由(游客态也允许访问)。
|
||||
*/
|
||||
export function isPublicRoute(pathname: string): pathname is StaticRoute {
|
||||
return pathname === ROUTES.chat || pathname === ROUTES.sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录用户访问入口路由时返回应跳转到的目标;其余情况返回 null。
|
||||
*
|
||||
* 当前实现仅做"splash/auth → chat"的重定向;未来若新增入口路由(如 onboarding),
|
||||
* 只需在此函数追加分支。
|
||||
*/
|
||||
export function resolveAuthedRedirect(pathname: string): string | null {
|
||||
if (isAuthOnlyRoute(pathname)) return ROUTES.chat;
|
||||
return null;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 客户端鉴权门 hook
|
||||
*
|
||||
* 单一真值 = `window.localStorage[StorageKeys.loginToken]`。
|
||||
* 故意不引入 Context/Zustand:本轮只有 splash/auth 两处使用,直接 hook 调用更直白。
|
||||
*
|
||||
* 实现要点(React 19 / Next.js 16 约定):
|
||||
* - 用 `useSyncExternalStore` 而非 `useEffect + setState`,避免 React 19
|
||||
* `react-hooks/set-state-in-effect` 规则的级联渲染告警。
|
||||
* - `getServerSnapshot` 返回 `false` → SSR 与客户端水合首帧都拿到一致值;
|
||||
* 水合完成后 React 自动重读 `getSnapshot()`(localStorage 真值),触发单次
|
||||
* 自然重渲染 → 调用方 `useEffect([isAuthed], ...)` 即捕获"已登录"事件。
|
||||
* - subscribe 用空函数(no-op):localStorage 在本应用生命周期内不会主动变化
|
||||
* (登录跳转走的是另一条 navigation 路径,重新进入 splash 时会重读)。
|
||||
*
|
||||
* 使用模式:
|
||||
* const { isAuthed } = useAuthGate();
|
||||
* useEffect(() => { if (isAuthed) router.replace(ROUTES.chat); }, [isAuthed]);
|
||||
* if (!isAuthed) return <Placeholder />; // SSR / 水合首帧 / 未登录 都会落在这
|
||||
*
|
||||
* 注:本轮不暴露 `ready` 标志——`useSyncExternalStore` 自身的 SSR / 客户端差异
|
||||
* 处理已经能保证水合首帧拿到 `false`(与 SSR 一致),无需调用方再判 ready。
|
||||
*
|
||||
* 注意:直接读 `window.localStorage` 是因为 `useSyncExternalStore.getSnapshot`
|
||||
* 必须是同步函数。`AuthStorage.hasLoginToken()` 是 async,无法在此处使用。
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
|
||||
export interface AuthGateState {
|
||||
/** 已登录(持有非空 `cozsweet.loginToken`) */
|
||||
isAuthed: boolean;
|
||||
}
|
||||
|
||||
// 静态 subscribe:localStorage 在本应用生命周期内不会主动变化。
|
||||
const subscribe = (): (() => void) => () => {};
|
||||
|
||||
/**
|
||||
* 同步读取 localStorage 中的 login_token(与 `AuthStorage.hasLoginToken` 语义一致)
|
||||
*/
|
||||
const getSnapshot = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const token = window.localStorage.getItem(StorageKeys.loginToken);
|
||||
return token !== null && token.length > 0;
|
||||
};
|
||||
|
||||
const getServerSnapshot = (): boolean => false;
|
||||
|
||||
export function useAuthGate(): AuthGateState {
|
||||
const isAuthed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return { isAuthed };
|
||||
}
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* `src/lib/` 手写 barrel
|
||||
*
|
||||
* 注意:故意**不**把 `src/lib/auth/use-auth-gate.tsx`(Client-only)放进来,
|
||||
* 注意:故意**不**把 `src/lib/auth/nextauth.ts`(Client-only)放进来,
|
||||
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
||||
* 如需导入 hook,请直接 `import { useAuthGate } from "@/lib/auth/use-auth-gate";`。
|
||||
* 如需导入,请直接 `import { ... } from "@/lib/auth/nextauth";`。
|
||||
*/
|
||||
|
||||
export * from "./routes";
|
||||
export * from "./auth/route-guards";
|
||||
export * from "./auth/nextauth-helpers";
|
||||
|
||||
Reference in New Issue
Block a user