0


详解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"exportconst{ handlers, signIn, signOut, auth }=NextAuth({providers:[Credentials({authorize:async(credentials)=>{let user =null// logic to salt and hash passwordconst pwHash =saltAndHashPassword(credentials.password)// logic to verify if user exists
        user =awaitgetUserFromDb(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 registrationthrownewError("User not found.")}// return user object with the their profile datareturn user
      },}),],})

在根目录下,新建文件

middleware.ts
import NextAuth from'next-auth';import{DEFAULT_LOGIN_REDIRECT,
apiAuthPrefix,
authRoutes,
publicRoutes,}from"@/routes"import{ auth }from'./auth';exportdefaultauth((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!returnnull;}if(isAuthRoute){if(isLoggedIn){return Response.redirect(newURL(DEFAULT_LOGIN_REDIRECT, nextUrl))}else{returnnull;}}if(!isLoggedIn &&!isPublicRoutes){return Response.redirect(newURL("/auth/login", nextUrl))}})// invoke the middle ware!exportconst config ={matcher:["/((?!.+\\.[\\w]+$|_next).*)","/","/(api|trpc)(.*)"],};

routes.ts

// Public Routesexportconst publicRoutes =["/",]// redirect logged in users to /settingsexportconst authRoutes =["/auth/login","/auth/register",]exportconst apiAuthPrefix ="/api/auth"exportconstDEFAULT_LOGIN_REDIRECT="/dashboard"
middleware.ts

为保留文件名,其中

config

变量定义了触发中间件方法的匹配规则。该文件中,定义了

auth

方法的过滤器。

route.ts

中定义公开路径、用于鉴权的路径、鉴权接口前缀及默认重定向地址。
在过滤方法中,返回null说明无需执行权限检查。对于公开路径及鉴权接口,无需登录即可访问。登录后,再访问注册和登录页面,会自动重定向到

DEFAULT_LOGIN_REDIRECT

定义的

/dashboard

路由中。
配置NextAuth路由:

api/auth/[...nextauth]/route.ts
import{ handlers }from"@/auth"exportconst{GET,POST}= handlers

2.注册页面

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

image.png

使用zod进行字段的合法性校验。在

schemas/index.ts

中,定义注册使用的schema:

import*as z from"zod"exportconst 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"interfaceRegisterFormPropsextendsReact.HTMLAttributes<HTMLDivElement>{}exportfunctionRegisterForm({ 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:""}});asyncfunctiononSubmit(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";exportconstregister=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 =awaitgetUserByEmail(email)if(existingUser){``return{
            error:"Email already in use!"}}awaitfetchMutation(api.user.create,{
        name,
        email,
        password: hasedPassword
    })// TODO : Send verification token emailreturn{// error: "Invalid fields!",
        success:"User Created"}}

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

bcryptjs

加密后,存入数据库中。

3.用户登录

image.png
同样使用zod进行登录表单的字段的合法性校验。在

schemas/index.ts

中,定义登录使用的schema:

import*as z from"zod"exportconst 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"interfaceLoginFormPropsextendsReact.HTMLAttributes<HTMLDivElement>{}exportfunctionLoginForm({ 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:""}});asyncfunctiononSubmit(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";exportconstlogin=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{awaitsignIn("credentials",{
            email, password,// redirect: true,
            redirectTo:DEFAULT_LOGIN_REDIRECT},)}catch(error){if(error instanceofAuthError){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,{typeDefaultSession}from"next-auth";import Credentials from"next-auth/providers/credentials";import{ LoginSchema }from"./schemas";exportconst{ auth, handlers, signIn, signOut }=NextAuth({
    providers:[Credentials({asyncauthorize(credentials){const validatedFields = LoginSchema.safeParse(credentials);if(validatedFields.success){const{ email, password }= validatedFields.data;const user =awaitgetUserByEmailFromDB(email);if(!user ||!user.password)returnnull;const passwordsMatch =await bcrypt.compare(password, user.password);if(passwordsMatch){return{...user,
                            id: user._id
                        }}}returnnull;},}),]}

其中,由于密码在注册时使用了

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"exportconstSocial=()=>{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";exportconst{ 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:{asyncsignIn({ user }){// console.log("AFTER SIGN IN !" + JSON.stringify(user));returntrue;},asyncjwt({ token, user, account, profile, isNewUser, session }){if(user){
        token.id = user.id
      }const notionAccount =awaitgetNotionAccount(token.id!!as Id<"users">)if(notionAccount){
        token.notion_token = notionAccount.access_token;}return token;},asyncsession({ 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错误问题。

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

3.事件 events

auth.ts
  events:{asynclinkAccount({ user }){if(user.id){awaitsetEmailVerified(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"exportconstlogout=async()=>{//  Some server stuffawaitsignOut();}

在客户端组件中使用:

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

6.使用Session获取用户信息

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

(protected)/layout.ts
import{ Suspense }from"react";import{ SessionProvider }from"next-auth/react";exportdefaultasyncfunctionDashboardLayout({
  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项目已可以完成所有业务功能,不需要再有独立的后端服务。对于小而美的项目,是一个快速落地的理想选择。

标签: github notion NextJS

本文转载自: https://blog.csdn.net/qq_22309741/article/details/139638923
版权归原作者 Ygria_ 所有, 如有侵权,请联系我们删除。

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

还没有评论