侧边栏壁纸
博主头像
术业有道之编程博主等级

亦是三月纷飞雨,亦是人间惊鸿客。亦是秋霜去叶多,亦是风华正当时。

  • 累计撰写 100 篇文章
  • 累计创建 53 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

next-auth组件的使用

Administrator
2025-10-07 / 0 评论 / 0 点赞 / 23 阅读 / 19221 字

写在前面

最开始使用nextjs还是 v122年时间已经到了 v15。最近在使用next-auth v5做认证授权组件,发现细节比较多,特此记录一下。

一、next-auth 是什么

它是一套基于nextjs框架的认证登录授权组件,包含如下方面:

  • 集成认证:包含大部分第三方平台的OAuth,如GoogleGitHub等等
  • 自定义认证:可以手动实现账号密码的方式授权(注意:这种方式要自己写注册逻辑)
  • 集成前端组件:扩展并支持前端框架,如react下直接使用相关的方法,比如获取用户信息等
  • 跨平台支持:v5版本独立为auth.js,不仅仅只在nextjs上使用

二、next-auth 的工作流程

我画了一张图来描述我当前用到的整个流程
next-auth.iodraw.svg

主要方法

  • providers中支持的认证方式

    • Google授权GitHub授权Credentials授权等等,有非常多的第三方 OAuth可选
    • 注意:如果是注册它会自动生成数据插入到数据库
    • 最终会将数据库中的user数据当作jwtuser入参值。所以数据库的字段名字类型要对应上
  • Credentials中的authorize

    • 用于实现自定义登录,比如去数据库查询账号密码是否匹配。
    • 注意:这里返回的对象就是jwt的方法入参user对象
    • 如果自定义了user字段,这里需要给自定义的字段赋值
  • callbacks中的signIn

    • 用于实现自定义登录认证逻辑,在next-auth默认处理完毕之后,这里可以自定义登录逻辑来最终决定登录是否成功。
  • callbacks中的jwt

    • 用于对即将创建的session会话一个token令牌;也可以刷新token令牌。
    • 注意:它的数据来源取决于前面的授权方式
    • 如果自定义了jwt字段,这里需要给自定义的字段赋值
    • 刷新时triggerupdate
  • callbacks中的session

    • 这就是当前的会话对象,一份在服务器端使用,一份在客户端使用
    • 如果自定义了session字段,这里需要给自定义的字段赋值
  • 前端使用 next-auth/react

    • const {data: session, status, update} = useSession();
    • session就是前面的对象,所以需要什么就塞入什么,但是不要放入敏感信息
    • update 用于不重新登录的情况下强制刷新jwtsession,这在需要往session中放入新的数据时很管用
    • 注意:这个数据刷新不是前端完成的,前端是通过这个方法触发后端根据当前最新的数据来重新生成jwtsession
  • 后端使用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.tssrc/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获取、更新

对于谷歌认证

0

评论区