详解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等钩子只能在客户端组件中使用。

实现步骤

代码框架搭建

node 复制代码
npx create-next-app@latest  

使用NextAuth(v5版本)

node 复制代码
npm install next-auth@beta  

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

json 复制代码
AUTH_SECRET=**********************

Credentials

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

参考: Credentials

1.基础配置

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

javascript 复制代码
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

javascript 复制代码
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

javascript 复制代码
// 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

javascript 复制代码
import { handlers } from "@/auth"
export const { GET, POST } = handlers

2.注册页面

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

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

ts 复制代码
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"
    })
})

注册页面代码(部分):

ts 复制代码
"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 处理注册用户入库:

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.用户登录

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

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

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

登录页面代码(部分):

ts 复制代码
"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 登录操作:

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方法中完成:

ts 复制代码
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

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

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

env 复制代码
GITHUB_CLIENT_ID=****
GITHUB_CLIENT_SECRET=****

2. 配置Github Provider

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

ts 复制代码
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中导入登录方法,并在点击按钮时触发即可。

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,并设置为公有

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

生成并复制Secrets:

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

json 复制代码
AUTH_NOTION_ID=""
AUTH_NOTION_SECRET=""
AUTH_NOTION_REDIRECT_URI="http://localhost:3001/api/auth/callback/notion"

2. 配置Notion Provider

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

ts 复制代码
 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范围。

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

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中增加如下配置:

ts 复制代码
 session: {
    // Set to jwt in order to CredentialsProvider works properly
    strategy: 'jwt'
  }

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

其他功能

1.回调 callbacks

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

auth.ts

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错误问题。

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

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

ts 复制代码
"use server"
import { signOut } from "@/auth"

export const logout = async () => {
    //  Some server stuff
    await signOut();
}

在客户端组件中使用:

ts 复制代码
import { logout } from "@/actions/logout";
function onClickSignOut() {
    logout();
}

6.使用Session获取用户信息

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

(protected)/layout.ts

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>
  );
}
ts 复制代码
import { useSession } from "next-auth/react";
const session = useSession();

总结

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

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

相关推荐
小牛itbull1 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
鱼满满记9 小时前
1.6K+ Star!GenAIScript:一个可自动化的GenAI脚本环境
人工智能·ai·github
梦魇梦狸º12 小时前
腾讯轻量云服务器docker拉取不到镜像的问题:拉取超时
docker·容器·github
Huazie17 小时前
一篇搞定 Hexo Diversity 主题接入!支持多主题自由切换!
javascript·github·hexo
草明2 天前
Nginx 做反向代理,一个服务优先被使用,当无法提供服务时才使用其他的备用服务
运维·nginx·github
马里嗷2 天前
Puppeteer - 掌控浏览器自动化的开源利器
后端·github
2301_796982142 天前
怎样使用pycharm的服务?
git·pycharm·github
yanlaifan2 天前
GitHub中搜索项目方法
github
油泼辣子多加2 天前
2024年11月5日Github流行趋势
github