• 详解Next Auth:自定义邮箱密码登录注册、Github、Notion授权 & Convex集成


    最近用NextJS框架做全栈项目做的很顺手,现在想给项目加上登录、注册、鉴权拦截、分角色路由控制等功能,并接入Github、Notion等第三方登录。
    可以使用NextJS官方提供的Auth框架实现。

    Intro

    阅读本篇,你将学会:
    1、登录、注册等逻辑,和如何接入第三方(以Github、Notion为例)
    2、建立用户、角色等数据模型,存储用户数据
    3、公开、私有路由守卫

    技术栈

    • NextJs(前端框架) v14.2.3
    • React(前端框架) 18
    • NextAuth(鉴权框架) v5.0.0-beta.18
    • Convex (后端接口 + ORM)

    背景知识学习

    在开始实现之前,需要知道NextJS中服务端组件和客户端组件的概念。
    NextJS中使用”use client“和”use server“标识服务端和客户端组件,客户端运行在浏览器中,服务端运行在服务器端。不标识时,默认为服务端组件。
    服务端组件用于异步请求等,负责与服务端交互、请求数据等,客户端组件主要用于和用户交互。React的钩子也有明确的区分,比如useEffect等钩子只能在客户端组件中使用。

    实现步骤

    代码框架搭建

    npx create-next-app@latest  
    

    使用NextAuth(v5版本)

    npm install next-auth@beta  
    

    开始之前,需要在环境变量文件.env.local中配置变量

    AUTH_SECRET=**********************
    

    Credentials

    我们首先实现一个简单的账号密码注册、登录、登出。
    参考: Credentials

    1.基础配置

    在项目根目录下,新建auth.js文件,并写入以下内容:

    import NextAuth from "next-auth"
    import Credentials from "next-auth/providers/credentials"
    // Your own logic for dealing with plaintext password strings; be careful!
    import { saltAndHashPassword } from "@/utils/password"
     
    export const { handlers, signIn, signOut, auth } = NextAuth({
      providers: [
        Credentials({
          authorize: async (credentials) => {
            let user = null
     
           // logic to salt and hash password
            const pwHash = saltAndHashPassword(credentials.password)
     
            // logic to verify if user exists
            user = await getUserFromDb(credentials.email, pwHash)
     
            if (!user) {
              // No user found, so this is their first attempt to login
              // meaning this is also the place you could do registration
              throw new Error("User not found.")
            }
     
            // return user object with the their profile data
            return user
          },
        }),
      ],
    })
    

    在根目录下,新建文件middleware.ts

    import NextAuth from 'next-auth';
    
    import {
    DEFAULT_LOGIN_REDIRECT,
    apiAuthPrefix,
    authRoutes,
    publicRoutes,
    } from "@/routes"
    
    import { auth } from './auth';
    export default auth((req) => {
    
    const { nextUrl } = req;
    // console.log("NEXT URL" + nextUrl.pathname)
    const isLoggedIn = !!req.auth;
    const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix);
    const isPublicRoutes = publicRoutes.includes(nextUrl.pathname);
    const isAuthRoute = authRoutes.includes(nextUrl.pathname);
    if (isApiAuthRoute) {
    // DO NOTHING!
    return null;
    
    }
    if (isAuthRoute) {
    if (isLoggedIn) {
    return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl))
    } else {
    return null;
    }
    }
    
    if (!isLoggedIn && !isPublicRoutes) {
    
    return Response.redirect(new URL("/auth/login", nextUrl))
    
    }
    
    })
    // invoke the middle ware!
    
    export const config = {
    
    matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
    
    };
    

    routes.ts

    // Public Routes
    export const publicRoutes = [
    "/",
    ]
    // redirect logged in users to /settings
    export const authRoutes = [
    "/auth/login",
    "/auth/register",
    ]
    export const apiAuthPrefix = "/api/auth"
    export const DEFAULT_LOGIN_REDIRECT = "/dashboard"
    

    middleware.ts为保留文件名,其中config变量定义了触发中间件方法的匹配规则。该文件中,定义了auth方法的过滤器。
    route.ts中定义公开路径、用于鉴权的路径、鉴权接口前缀及默认重定向地址。
    在过滤方法中,返回null说明无需执行权限检查。对于公开路径及鉴权接口,无需登录即可访问。登录后,再访问注册和登录页面,会自动重定向到DEFAULT_LOGIN_REDIRECT定义的/dashboard路由中。
    配置NextAuth路由:
    api/auth/[...nextauth]/route.ts

    import { handlers } from "@/auth"
    export const { GET, POST } = handlers
    

    2.注册页面

    实现形如下图的注册页面,核心为可提交的表单,包含name、email、password等字段。

    image.png

    使用zod进行字段的合法性校验。在schemas/index.ts中,定义注册使用的schema:

    import * as z from "zod"
    export const RegisterSchema = z.object({
        email: z.string().email({
            message: "Email is Required."
        }),
        password: z.string().min(6,{
            message: "Minimum 6 characters required",
        }),
        name: z.string().min(1,{
            message: "Name is Required"
        })
    })
    

    注册页面代码(部分):

    "use client"
    import { useState, useTransition } from "react"
    import { cn } from "@/lib/utils"
    import * as z from "zod"
    import { zodResolver } from "@hookform/resolvers/zod"
    import { register } from "@/actions/register"
    interface RegisterFormProps extends React.HTMLAttributes<HTMLDivElement> { }
    export function RegisterForm({ className, ...props }: RegisterFormProps) {
      const [isPending, startTransition] = useTransition();
      const [error, setError] = useState<string | undefined>("");
      const [success, setSuccess] = useState<string | undefined>("");
      const form = useForm<z.infer<typeof RegisterSchema>>({
        resolver: zodResolver(RegisterSchema),
        defaultValues: {
          name: "",
          email: "",
          password: ""
        }
      });
      async function onSubmit(values: z.infer<typeof RegisterSchema>) {
        setError("")
        setSuccess("")
        startTransition(() => {
          register(values).then((data) => {
            setError(data.error)
            setSuccess(data.success)
          })
        })
      }
      return (
        <div className={cn("grid gap-6", className)} {...props}>  
              <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
              // form field inputs
                <Button type="submit" className="w-full" disabled={isPending}>Create an account</Button>
              </form>
        </div>
      )
    }
    

    actions/register.ts 处理注册用户入库:

    "use server";
    
    import * as z from "zod"
    import { RegisterSchema } from "@/schemas";
    import  bcrypt  from "bcryptjs"
    import { api } from "@/convex/_generated/api";
    import { fetchMutation, fetchQuery } from "convex/nextjs";
    import { getUserByEmail } from "@/data/user";
    
    export const register = async (values: z.infer<typeof RegisterSchema>) => {
        const validatedFields = RegisterSchema.safeParse(values);
        if (!validatedFields.success) {
            return {
                error: "Invalid fields!"
            }
        }
        const { email, password, name } = validatedFields.data;
        const hasedPassword = await bcrypt.hash(password, 10)
        const existingUser = await getUserByEmail(email)
        if (existingUser) {
            ``
            return {
                error: "Email already in use!"
            }
        }
        await fetchMutation(api.user.create, {
            name,
            email,
            password: hasedPassword
        })
        // TODO : Send verification token email
        return {
            // error: "Invalid fields!",
            success: "User Created"
        }
    }
    

    用户在注册页面填写名称、邮箱、密码后,点击submit按钮,客户端组件调用了服务组件方法,先查询邮箱是否被占用,未被占用,将明文密码使用bcryptjs加密后,存入数据库中。

    3.用户登录

    image.png
    同样使用zod进行登录表单的字段的合法性校验。在schemas/index.ts中,定义登录使用的schema:

    import * as z from "zod"
    export const LoginSchema = z.object({
        email: z.string().email(),
        password: z.string().min(1)
    })
    

    注意:不应限制用户填入密码规则。虽然注册时限定了用户填写的密码至少6位,但系统的密码规则有可能变更。

    登录页面代码(部分):

    "use client"
    import { useState, useTransition } from "react"
    import { cn } from "@/lib/utils"
    import { useForm } from "react-hook-form"
    import { LoginSchema } from "@/schemas"
    import * as z from "zod"
    import { zodResolver } from "@hookform/resolvers/zod"
    import { login } from "@/actions/login"
    interface LoginFormProps extends React.HTMLAttributes<HTMLDivElement> { }
    
    export function LoginForm({ className, ...props }: LoginFormProps) {
      const [isPending, startTransition] = useTransition();
      const [error, setError] = useState<string | undefined>("");
      const [success, setSuccess] = useState<string | undefined>("");
      const form = useForm<z.infer<typeof LoginSchema>>({
        resolver: zodResolver(LoginSchema),
        defaultValues: {
          email: "",
          password: ""
        }
      });
    
      async function onSubmit(values: z.infer<typeof LoginSchema>) {
        setError("")
        setSuccess("")
        startTransition(() => {
          login(values).then((data) => {
            if (data && data.error) {
              setError(data.error)
            }
          })
        })
      }
     return (
        <div className={cn("grid gap-6", className)} {...props}>
              <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
                <Button type="submit" className="w-full" disabled={isPending}>Login</Button>
              </form>
        </div>
      )
    }
    
    

    actions/login.ts 登录操作:

    "use server";
    import { signIn } from "@/auth";
    import * as z from "zod"
    import { LoginSchema } from "@/schemas";
    import { DEFAULT_LOGIN_REDIRECT } from "@/routes";
    import { AuthError } from "next-auth";
    export const login = async (values: z.infer<typeof LoginSchema>) => {
        const validatedFields = LoginSchema.safeParse(values);
        if (!validatedFields.success) {
            return {
                error: "Invalid fields!"
            }
        }
        const { email, password } = validatedFields.data;
        console.log("EMAIL!" + email + "PASSWORD!" + password)
    
        try {
            await signIn("credentials", {
                email, password,
                // redirect: true,
                redirectTo: DEFAULT_LOGIN_REDIRECT
            },)
        } catch (error) {
            if (error instanceof AuthError) {
                switch (error.type) {
                    case "CredentialsSignin":
                        return { error: "Invalid Credentials!" }
                    default:
                        return { error: "Something went wrong!" }
                }
            }
            throw error;
        }
    }
    

    可以看到,该操作从@/auth中导入了SignIn方法,第一个参数指定了Provider,实际的授权在Provider定义的authorize方法中完成:

    import bcrypt from "bcryptjs";
    import NextAuth, { type DefaultSession } from "next-auth";
    import Credentials from "next-auth/providers/credentials";
    import { LoginSchema } from "./schemas";
    
    export const { auth, handlers, signIn, signOut } = NextAuth({
        providers: [
            Credentials({
                async authorize(credentials) {
                    const validatedFields = LoginSchema.safeParse(credentials);
                    if (validatedFields.success) {
                        const { email, password } = validatedFields.data;
                        const user = await getUserByEmailFromDB(email);
                        if (!user || !user.password) return null;
    
                        const passwordsMatch = await bcrypt.compare(password, user.password);
                        if (passwordsMatch) {
                            return {
                                ...user,
                                id: user._id
                            }
                        }
                    }
                    return null;
                },
    
            }),]
    
    }
    

    其中,由于密码在注册时使用了bcryptjs加密,所以比较时也要使用bcryptjs提供的match方法。至此,使用邮箱和密码登录注册的简单逻辑已完成。

    Github Provider

    1. 新建Github Oauth App

    https://github.com/settings/developers下,新建Oauth Apps

    image.png

    Callback url很重要,一定是你的站点host+port,后面配置为Next Auth默认回跳地址/api/auth/callback/github。(我当前配置为开发用,生产时需要改为线上地址。)

    image.png
    配置完成后,将Client ID 和Client Secret粘贴到配置文件中。
    .env.local

    GITHUB_CLIENT_ID=****
    GITHUB_CLIENT_SECRET=****
    

    2. 配置Github Provider

    auth.ts的Providers数组中,加入:

    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
      allowDangerousEmailAccountLinking: true
    }),
    

    allowDangerousEmailAccountLinking是允许使用同一邮箱的Github、Notion账号互联。若不配置该属性,使用同一邮箱注册的Notion和Github(或其他第三方)登录将被拦截。当使用的Providers来自于不可靠的OAuth App厂商,须谨慎使用该属性。

    3.页面触发Github登录

    social.tsx
    @/auth.ts中导入登录方法,并在点击按钮时触发即可。

    "use client"
    import { FaGithub } from "react-icons/fa"
    import { SiNotion } from "react-icons/si";
    import { Button } from "@/components/ui/button"
    import { signIn } from "next-auth/react"
    import { DEFAULT_LOGIN_REDIRECT } from "@/routes"
    
    export const Social = () => {
        const onClick = (provider: "github" | "notion") => {
            signIn(provider, {
                callbackUrl: DEFAULT_LOGIN_REDIRECT
            })
        }
    
        return (
            <div className="flex items-center w-full gap-x-2">
                <Button size="lg" className="w-full" variant="outline" onClick={() => onClick("github")}>
                    <FaGithub className="h-5 w-5"></FaGithub>
                </Button>
                <Button size="lg" className="w-full" variant="outline" onClick={() => onClick("notion")}>
                    <SiNotion className="h-5 w-5" />
                </Button>
    
    
            </div>
        )
    }
    

    首次点击时,会跳转到Github询问是否授权。之后会直接点击后跳转鉴权并登录。

    Notion Provider

    Notion登录的配置方法与Github非常类似。

    1. 配置Public Ingegration

    前往https://www.notion.so/my-integrations ,新建一个Integration,并设置为公有

    image.png
    需将回调地址配置为/api/auth/callback/notion

    image.png

    生成并复制Secrets:

    image.png

    将Client ID和Secret复制到配置文件中。

    AUTH_NOTION_ID=""
    AUTH_NOTION_SECRET=""
    AUTH_NOTION_REDIRECT_URI="http://localhost:3001/api/auth/callback/notion"
    

    2. 配置Notion Provider

    auth.ts的Providers数组中,加入:

     Notion({
          clientId: process.env.AUTH_NOTION_ID,
          clientSecret: process.env.AUTH_NOTION_SECRET,
          redirectUri: process.env.AUTH_NOTION_REDIRECT_URI,
          allowDangerousEmailAccountLinking: true
        }),
    

    需传入redirectUri(该uri必须配置到上一步的Ingegration中)

    使用Notion登录

    登录的触发方法与Github相同,不赘述。点击登录时,会多出一步,可以选择授权访问的page范围。

    image.png

    Convex Adapter

    参考文档:

    1. Auth.js | Creating A Database Adapter
    2. https://stack.convex.dev/nextauth-adapter

    本应用使用了Convex作为后台API提供,所以需要实现Convex Adapter。按https://stack.convex.dev/nextauth-adapter 步骤操作即可。

    auth.ts

    import NextAuth from "next-auth"
    import { ConvexAdapter } from "@/app/ConvexAdapter";
     
    export const { handlers, auth, signIn, signOut } = NextAuth({
      providers: [],
      adapter: ConvexAdapter,
    })
    
    

    注意避坑!!!!

    这里遇到了一个坑,配置了Convex Adapter后,使用邮箱密码再也无法登陆。经过调试发现是通过Credentials登录时,没有调用createSession方法,session没有创建。在Github上搜索后,发现有人遇到类似问题:https://github.com/nextauthjs/next-auth/issues/3970

    在auth.ts中增加如下配置:

     session: {
        // Set to jwt in order to CredentialsProvider works properly
        strategy: 'jwt'
      }
    

    即可解决问题!!(卡了好久,真的有点坑……教训就是遇到问题先上github搜搜issue,我一直试图手动自己往数据库塞入一个session未果……)

    其他功能

    1.回调 callbacks

    在回调中,可以向session加入自定义属性

    auth.ts

      callbacks: {
        async signIn({ user }) {
          // console.log("AFTER SIGN IN !" + JSON.stringify(user));
          return true;
        },
    
        async jwt({ token, user, account, profile, isNewUser, session }) {
          if (user) {
            token.id = user.id
          }
          const notionAccount = await getNotionAccount(token.id!! as Id<"users">)
          if (notionAccount) {
            token.notion_token = notionAccount.access_token;
          
          }
          return token;
        },
    
        async session({ token, session }) {
          if (token.sub && session.user) {
            session.user.id = token.sub;
          }
          if (token.notion_token && session.user) {
            session.user.notion_token = token.notion_token;
          }
    
          return session;
        },
      },
    

    执行顺序: signIn => jwt => session

    可在session中读到在jwt方法返回的token值,可将需要的属性放到session中,如角色、权限等。此处我将Notion的secret放到session中,以便业务代码中取用。

    2.自定义Session类型

    在auth.ts中加入如下代码,可解决自定义session中的属性报ts错误问题。

    declare module "next-auth" {
      /**
       * Returned by `auth`, `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
       */
      interface Session {
        user: {
          role: string;
          notion_token: string;
        } & DefaultSession["user"];
      }
    }
    
    

    3.事件 events

    auth.ts

      events: {
        async linkAccount({ user }) {
          if (user.id) {
            await setEmailVerified(user.id)
          }
        },
      },
    

    一个用户(user)可连接多个account(由github、notion等提供的第三方OAuth账号),可设置当连接用户时,将用户设置为邮箱已验证(通过github、notion等可靠app登录的用户无需二次验证邮箱。)

    4.页面

    auth.ts

      pages: {
        signIn: "/auth/login",
        error: "/auth/error"
      },
    

    指定登录页、自定义鉴权出错页。

    5.登出

    action/logout.ts

    "use server"
    import { signOut } from "@/auth"
    
    export const logout = async () => {
        //  Some server stuff
        await signOut();
    }
    

    在客户端组件中使用:

    import { logout } from "@/actions/logout";
    function onClickSignOut() {
        logout();
    }
    

    6.使用Session获取用户信息

    在客户端调用useSession(),需要在SessionProvider的包裹下使用。

    (protected)/layout.ts

    import { Suspense } from "react";
    
    import { SessionProvider } from "next-auth/react";
    
    export default async function DashboardLayout({
      children, // will be a page or nested layout
    }: {
      children: React.ReactNode;
    }) {
      return (
        <SessionProvider>
            <div>{children}</div>     
        </SessionProvider>
      );
    }
    
    
    import { useSession } from "next-auth/react";
    const session = useSession();
    

    总结

    NextAuth能帮助你快速集成第三方登录,也支持灵活的自定义账号、密码登录。

    借助ORM框架和一些中间件,一个NextJS项目已可以完成所有业务功能,不需要再有独立的后端服务。对于小而美的项目,是一个快速落地的理想选择。

  • 相关阅读:
    计算机网络
    《STM32 HAL库》RCC 相关系列函数详尽解析—— HAL_RCC_OscConfig()
    虚拟内存技术的来龙去脉(上)
    国际版阿里云/腾讯云:弹性高性能计算E-HPC入门概述
    好饭不怕晚,Google基于人工智能AI大语言对话模型Bard测试和API调用(Python3.10)
    Softing pnGate系列网关:将PROFIBUS总线集成到PROFINET网络
    mysql数据库的授权访问
    如何更改照片底色(免费)
    仓库管理系统到底是什么?有必要专门买一个吗?
    为什么 Python 代码在函数中运行得更快?
  • 原文地址:https://blog.csdn.net/qq_22309741/article/details/139638923