写在前面
最开始使用nextjs还是 v12,2年时间已经到了 v15。最近在使用next-auth v5做认证授权组件,发现细节比较多,特此记录一下。
一、next-auth 是什么
它是一套基于nextjs框架的认证登录授权组件,包含如下方面:
- 集成认证:包含大部分第三方平台的
OAuth,如Google、GitHub等等 - 自定义认证:可以手动实现
账号密码的方式授权(注意:这种方式要自己写注册逻辑) - 集成前端组件:扩展并支持前端框架,如
react下直接使用相关的方法,比如获取用户信息等 - 跨平台支持:v5版本独立为
auth.js,不仅仅只在nextjs上使用
二、next-auth 的工作流程
我画了一张图来描述我当前用到的整个流程
主要方法
-
providers中支持的认证方式Google授权、GitHub授权、Credentials授权等等,有非常多的第三方 OAuth可选- 注意:如果是注册它会自动生成数据插入到数据库
- 最终会将数据库中的
user数据当作jwt的user入参值。所以数据库的字段名字类型要对应上
-
Credentials中的authorize- 用于实现自定义登录,比如去数据库查询账号密码是否匹配。
- 注意:这里返回的对象就是
jwt的方法入参user对象 - 如果自定义了
user字段,这里需要给自定义的字段赋值
-
callbacks中的signIn- 用于实现自定义登录认证逻辑,在
next-auth默认处理完毕之后,这里可以自定义登录逻辑来最终决定登录是否成功。
- 用于实现自定义登录认证逻辑,在
-
callbacks中的jwt- 用于对即将创建的
session会话一个token令牌;也可以刷新token令牌。 - 注意:它的数据来源取决于前面的授权方式
- 如果自定义了
jwt字段,这里需要给自定义的字段赋值 - 刷新时
trigger是update
- 用于对即将创建的
-
callbacks中的session- 这就是当前的会话对象,一份在服务器端使用,一份在客户端使用
- 如果自定义了
session字段,这里需要给自定义的字段赋值
-
前端使用
next-auth/reactconst {data: session, status, update} = useSession();session就是前面的对象,所以需要什么就塞入什么,但是不要放入敏感信息update用于不重新登录的情况下强制刷新jwt和session,这在需要往session中放入新的数据时很管用- 注意:这个数据刷新不是前端完成的,前端是通过这个方法触发后端根据当前最新的数据来重新生成
jwt和session
-
后端使用
export const {handlers, auth, signIn, signOut} = NextAuth(authConfig)- 可以在任意地方使用
auth,来获取session对象。例如写一个api之后不需要用户携带任何信息,通过这个方法获取当前的访问用户信息。
- 可以在任意地方使用
三、当前的使用方式
安装包
- "next-auth": "^5.0.0-beta.29",
- "undici": "^7.16.0",
- "bcryptjs": "^3.0.2",
后端
- 创建网络代理(
src/lib/undici-proxy-setup.ts)
import { setGlobalDispatcher, ProxyAgent } from "undici";
if (process.env.HTTP_PROXY) {
// 使用代理来访问谷歌等服务
setGlobalDispatcher(new ProxyAgent(process.env.HTTP_PROXY));
console.log("[Undici] Proxy enabled →", process.env.HTTP_PROXY);
}
- 创建自定义的数据类型(
src/types/next-auth.d.ts)
import {DefaultSession} from "next-auth";
import {DefaultJWT} from "@auth/core/jwt";
import {DefaultUser} from "@auth/core/types";
// 扩展 User 接口,包含数据库中的 fps 字段
declare module "@auth/core/types" {
interface User extends DefaultUser {
fps?: any; // 假设 fps 是 JSON 类型
inviteCode?: string; // 推荐码
}
// 扩展 Session 接口,让前端可以通过 session.user.fps 访问
interface Session extends DefaultSession {
user: {
id?: string;
fps?: any;
inviteCode?: string; // 推荐码
} & DefaultSession["user"];
provider?: string;
googleId?: string;
inviteCode?: string;
}
}
// 扩展 JWT 接口,用于在服务器端传递数据
declare module "@auth/core/jwt" {
interface JWT extends DefaultJWT {
fps?: any;
id?: string;
inviteCode?: string; // 推荐码
provider?: string;
googleId?: string;
}
}
- 创建
api路由(src/app/api/auth/[...nextauth]/route.ts)
import "@/lib/undici-proxy-setup"; // 必须放在最前面。这是本地网络代理,当需要访问谷歌授权时非常有用
import NextAuth from "next-auth";
import {authConfig} from "@/lib/auth.config"; // 这是本地核心的auth实现
// 使用导出的配置创建 NextAuth 处理器
export const {handlers, auth, signIn, signOut} = NextAuth(authConfig);
// 导出 GET 和 POST 处理器
export const {GET, POST} = handlers;
- 创建
auth.config.ts(src/lib/auth.config.ts)
import type {NextAuthConfig} from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import {comparePasswords, getFp} from "@/lib/auth";
import {GoogleProfile} from "@auth/core/providers/google";
import prisma from "@/lib/prisma";
import {PrismaAdapter} from "@auth/prisma-adapter";
// fp 最大个数
const MAX_FPS_ENTRIES = Number(process.env.MAX_FPS_ENTRIES!)
export const authConfig: NextAuthConfig = {
adapter: PrismaAdapter(prisma),
// --- 1. 配置提供者 (Providers) ---
providers: [
// 1. Google 提供者
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
scope: [
'openid',
'https://www.googleapis.com/auth/userinfo.email',// 用户的邮箱地址(email)
'https://www.googleapis.com/auth/userinfo.profile' // 用户的基本资料(姓名、头像等)
].join(' ')
}
},
}),
// 2. 凭证提供者 (Credentials)
Credentials({
// 定义期望接收的字段
credentials: {
email: {label: "Email", type: "email"},
password: {label: "Password", type: "password"},
},
// 授权逻辑:根据传入的凭证验证用户
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("Please enter email and password");
}
const userDb = await prisma.user.findFirst({
where: {email: credentials.email, disable: false},
});
if (!userDb || !userDb.password) {
throw new Error("No user found with this email");
}
const isValid = comparePasswords(credentials.password, userDb.password);
if (!isValid) {
throw new Error("Invalid password");
}
// 这里的返回值对应 jwt
return {
id: userDb.id,
name: userDb.name,
email: userDb.email,
image: userDb.image,
fps: userDb.fps,
};
},
}),
],
// --- 2. 配置回调 (Callbacks) ---
// 用于将会话数据(session)和 JSON Web Token (JWT) 连接起来
callbacks: {
// signIn 回调:在用户注册/登录成功但会话创建前执行
async signIn({user, account, profile}) {
if (!user || !user.email) {
console.error("注册/登录失败,没有获取到用户登录信息")
return false
}
try {
// 仅在新用户注册时(fps字段为null)进行初始化
// 适配器会在此时创建用户,但 fps 字段可能为空
const dbUser = await prisma.user.findFirst({
where: {email: user.email!, disable: false},
select: {fps: true},
});
// 将当前的fp数据放入 user fps中
const fp = await getFp()
if (fp) {
let existingFps = Array.isArray(dbUser?.fps) ? dbUser.fps : [];
// 1. 移除旧的重复指纹 (确保新指纹是唯一的且是最新的)
const existingIndex = existingFps.indexOf(fp);
if (existingIndex > -1) {
existingFps.splice(existingIndex, 1);
}
// 2. 将最新的 fp 添加到数组的最前面
existingFps.unshift(fp);
// 3. 滚动删除:限制最大条数,删除最旧的 (数组末尾)
if (existingFps.length > MAX_FPS_ENTRIES) {
// 从索引 MAX_FPS_ENTRIES 开始,删除所有后续元素
existingFps.splice(MAX_FPS_ENTRIES);
}
await prisma.user.update({
where: {email: user.email!},
data: {
fps: existingFps,
},
});
}
return true; // 允许登录
} catch (e: any) {
console.error(`处理注册/登录异常:${e}`)
return false;
}
},
/**
* 每次创建或更新 JWT 时调用
* 在 Credentials 中 authorize 的返回值等于就是 jwt 的值,
* jwt 方法中其实不需要额外对 Credentials 进行补充,
* 只是在 Google 登录注册时需要手动给 jwt 加上来自 Google 的email, name, picture字段
* 注意:OAuth 首次注册的瞬间,user 可能不包含 fps,所以需要判断一下进行数据库回查
*/
async jwt({token, account, profile, user, trigger}) {
// 1. 如果 user 对象存在(Credentials 登录或 Adapter 成功返回)
if (user) {
token.id = user.id as string;
token.inviteCode = user.inviteCode;
let fpsData = user.fps;
// 仅在 user 对象缺乏 fps 数据时(OAuth 首次注册的瞬间)进行数据库回查。
// 确保我们拿到 signIn 回调刚刚更新的数据。
if (!fpsData && user.email) {
const dbUser = await prisma.user.findUnique({
where: {email: user.email},
select: {fps: true},
});
fpsData = dbUser?.fps;
}
// 将最终数据添加到 token
token.fps = fpsData ?? [];
token.provider = account?.provider ?? token.provider; // 从当前 account 覆盖 provider
// 2. 处理 Google OAuth 特有字段 (仅在登录成功时 account 存在)
if (account?.provider === "google" && profile) {
const googleProfile = profile as GoogleProfile;
token.googleId = googleProfile.sub;
token.email = googleProfile.email; // 确保邮箱更新
token.name = googleProfile.name;
token.picture = googleProfile.picture;
}
}
// 前端使用了 update 方法刷新jwt,这里需要对应处理
// 注意:如果没有 user 对象,通常 token 已经是旧的 session token,直接返回。
if (token.email && (!token.inviteCode || trigger === 'update')) {
// 使用 token 中的 id 查询数据库
const dbUser = await prisma.user.findUnique({
where: {email: token.email},
// 仅选择你需要更新的字段,以提高性能
select: {inviteCode: true},
});
// 将最新的 inviteCode 写入 token
if (dbUser) {
token.inviteCode = dbUser.inviteCode;
}
}
return token;
},
// 每次读取 Session 时调用
session({session, token}) {
// 通用字段
if (token.id) session.user.id = token.id;
if (token.fps) session.user.fps = token.fps;
if (token.inviteCode) session.inviteCode = token.inviteCode; // 根部 inviteCode
// 默认字段 (通常不需要再次检查,但为了安全保留)
if (token.name) session.user.name = token.name;
if (token.email) session.user.email = token.email;
if (token.picture) session.user.image = token.picture;
// Google & Provider 字段
if (token.provider) session.provider = token.provider;
if (token.googleId) session.googleId = token.googleId;
return session;
},
},
// --- 3. 配置 Session 策略 ---
session: {
strategy: "jwt",
},
// --- 4. 配置页面 (可选) ---
// pages: {
// signIn: "/auth/signin", // 自定义登录页路由
// },
debug: process.env.NODE_ENV === "development",
secret: process.env.AUTH_SECRET,
};
- 创建后端全局通用工具(
src/app/lib/auth.ts)
import {cookies} from "next/headers";
import bcrypt from 'bcryptjs'
import {ReadonlyRequestCookies} from "next/dist/server/web/spec-extension/adapters/request-cookies";
import {auth} from "@/app/api/auth/[...nextauth]/route";
import {fromBase64} from "@/lib/utlis";
import {User} from "@auth/core/types";
export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10)
return await bcrypt.hash(password, salt)
}
export async function comparePasswords(password: string, hashedPassword: string): Promise<boolean> {
return await bcrypt.compare(password, hashedPassword)
}
export async function getSession() {
return await auth();
}
export async function getCookies(name: any) {
const cookieStore: ReadonlyRequestCookies = await cookies();
return cookieStore.get(name)
}
const NEXT_COOKIE_FP = process.env.NEXT_COOKIE_FP!
export async function getFp() {
const cookie = await getCookies(NEXT_COOKIE_FP)
// base64
if (cookie && cookie.value) {
return fromBase64(cookie.value)
}
return null
}
export async function getAuthInfo(error: boolean = true) {
let auth: any
const session = await getSession()
if (session) {
auth = session.user
}
if (!auth && error) {
throw new Error("user 不能为空")
}
// 检查fp列表是否合法
if (auth) {
await checkFp(auth)
}
return auth
}
async function checkFp(auth: User) {
if (auth.fps && Array.isArray(auth.fps)) {
// 1. 异步获取当前指纹 (fp)
const fp = await getFp();
// 2. 检查获取到的 fp 是否存在于 session.user.fps 数组中
const isAuthorized = auth.fps.includes(fp);
if (!isAuthorized) {
console.log("用户指纹未授权 (Unauthorized)");
// 在此执行未授权时的操作
// 例如: 阻止访问或重定向
throw new Error("用户指纹未授权 (Unauthorized)")
}
} else {
console.log("会话数据不可用或 FPS 为空");
}
}
- 使用
import {NextRequest, NextResponse} from 'next/server';
import {getAuthInfo} from "@/lib/auth";
import {createErrorResponse} from "@/lib/errorUtils";
import prisma from "@/lib/prisma";
import {UserInfoParameters} from "@/types/user";
import {User} from "@auth/core/types";
export async function GET(
request: NextRequest,
{params}: { params: { action: string } }
) {
try {
const authInfo = await getAuthInfo() // 这里可以直接获得当前用户的 session
const {action} = params
// 根据action路由到不同处理逻辑
switch (action) {
case 'info':
return await handleInfo(request, authInfo)
default:
throw new Error('Invalid operation type')
}
} catch (error: any) {
console.error('teacher error:', error)
return NextResponse.json(
{success: false, msg: createErrorResponse(error)}
)
}
}
前端
- 自定义登录
import {signIn} from "next-auth/react";
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
try {
const result = await signIn("credentials", {
redirect: false,// 不进行重定向
email,
password,
});
if (result?.error) {
setError(result.error);
} else {
onClose();
router.refresh()
}
} catch (error) {
setError(error)
} finally {
setLoading(false);
}
};
- 授权登录
import { signIn } from "next-auth/react";
const handleGoogleLogin = async () => {
setIsLoading(true);
// 1. 禁用重定向:让 NextAuth.js 返回重定向 URL 而不是执行重定向
const response = await signIn('google', {
redirect: false
});
if (response?.url) {
// NextAuth.js 返回的 URL 实际上是 Google 认证的 URL
const authUrl = response.url;
const features = getPopupFeatures(POPUP_CONFIG);
// 2. 打开弹出窗口
const popup = window.open(authUrl, 'GoogleAuthPopup', features);
// 3. 监听窗口关闭(这是 Popup 模式下最简单的通信方式)
// NextAuth.js 在认证成功后会重定向回 /api/auth/callback/google,
// 然后再重定向回应用主页,完成认证流程后会关闭该窗口。
const timer = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(timer);
setIsLoading(false);
// 4. 认证完成后,刷新或通知主应用检查 Session 状态
console.log("Google Auth Popup closed. Checking session...");
// 触发 session 刷新,NextAuth.js 会自动更新 Session 状态
window.location.reload();
}
}, 500);
} else {
setIsLoading(false);
console.error("Failed to get Google Auth URL or other error:", response);
}
};
- 退出登录
import {signOut} from "next-auth/react";
const handleSignOut = async () => {
await signOut();
};
- 获取登录的用户信息,并刷新
session
import React, {useEffect, useState} from 'react';
import {useSession, signOut} from "next-auth/react";
const {data: session, status, update} = useSession();
const handleUpdateSession = async () => {
// 强制刷新jwt和session。实际上它会调用到auth.config.ts中的jwt方法,trigger 是 update
// 注意:这个数据刷新不是前端完成的,前端是通过这个方法触发后端根据当前最新的数据来重新生成jwt和session
await update()
};
useEffect(() => {
if (session) {
// 已登录
} else {
// 未登录
}
}, []);
当前代码完成的功能如下:
- 谷歌认证、自定义账号密码认证
- 基于
fp浏览器指纹防止jwt盗用转移 - 全局
session获取、更新
对于谷歌认证
-
需要前往 Google Auth Platform

评论区