详解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项目已可以完成所有业务功能,不需要再有独立的后端服务。对于小而美的项目,是一个快速落地的理想选择。

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是一个简单的SpringBoot集成SpringSecurity的例子,实现自定义登录和授权。 1. 添加依赖 在`pom.xml`文件中添加如下依赖: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> ``` 2. 配置SpringSecurity 在`application.properties`文件中添加如下配置: ```properties # 禁用csrf security.enable-csrf=false # 配置自定义登录页面 spring.security.login-form=/login # 配置自定义登录接口 spring.security.login-processing-url=/login ``` 3. 创建自定义UserDetailsService实现类 创建一个实现`UserDetailsService`接口的类`CustomUserDetailsService`,用于从数据库中获取用户信息。在该类中,我们需要注入一个`UserRepository`,用于从数据库中获取用户信息。 ```java @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("User not found with username: " + username); } return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), new ArrayList<>()); } } ``` 4. 创建自定义User实体类和Repository接口 创建一个`User`实体类,用于表示用户信息,并创建一个`UserRepository`接口,用于从数据库中获取用户信息。 ```java @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; // getter and setter methods } @Repository public interface UserRepository extends JpaRepository<User, Long> { User findByUsername(String username); } ``` 5. 创建自定义授权规则 创建一个实现`GrantedAuthority`接口的类`CustomGrantedAuthority`,用于表示用户的授权角色。在该类中,我们需要定义一个`role`属性,表示用户的角色。 ```java public class CustomGrantedAuthority implements GrantedAuthority { private String role; public CustomGrantedAuthority(String role) { this.role = role; } @Override public String getAuthority() { return role; } } ``` 6. 创建自定义AuthenticationProvider实现类 创建一个实现`AuthenticationProvider`接口的类`CustomAuthenticationProvider`,用于自定义用户的认证和授权规则。在该类中,我们需要注入`UserDetailsService`和`PasswordEncoder`对象,并实现`authenticate()`方法和`supports()`方法。 ```java @Component public class CustomAuthenticationProvider implements AuthenticationProvider { @Autowired private UserDetailsService userDetailsService; @Autowired private PasswordEncoder passwordEncoder; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = authentication.getCredentials().toString(); UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (userDetails == null) { throw new UsernameNotFoundException("User not found with username: " + username); } if (!passwordEncoder.matches(password, userDetails.getPassword())) { throw new BadCredentialsException("Invalid password"); } Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new CustomGrantedAuthority("ROLE_USER")); return new UsernamePasswordAuthenticationToken(username, password, authorities); } @Override public boolean supports(Class<?> authentication) { return authentication.equals(UsernamePasswordAuthenticationToken.class); } } ``` 7. 创建自定义登录页面和控制器 在`resources/templates`目录下创建一个`login.html`文件,用于自定义登录页面。在`com.example.demo.controller`包下创建一个`LoginController`类,用于处理登录请求。 ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Login Page</title> </head> <body> <h3>Login Page</h3> <form action="/login" method="post"> <input type="text" name="username" placeholder="Username" required/><br> <input type="password" name="password" placeholder="Password" required/><br> <input type="submit" value="Login"/> </form> </body> </html> ``` ```java @Controller public class LoginController { @GetMapping("/login") public String login() { return "login"; } @GetMapping("/home") public String home() { return "home"; } } ``` 8. 创建自定义授权规则配置类 创建一个实现`WebSecurityConfigurerAdapter`类的`CustomWebSecurityConfigurer`类,用于自定义授权规则。在该类中,我们需要注入`CustomAuthenticationProvider`对象,并重写`configure()`方法。 ```java @Configuration @EnableWebSecurity public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter { @Autowired private CustomAuthenticationProvider customAuthenticationProvider; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(customAuthenticationProvider); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/home").hasRole("USER") .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .permitAll() .and() .logout() .permitAll(); } @Bean public PasswordEncoder getPasswordEncoder() { return new BCryptPasswordEncoder(); } } ``` 9. 运行程序 启动应用程序后,访问`http://localhost:8080/login`,将跳转到自定义的登录页面。输入正确的用户名和密码,将跳转到自定义的`home`页面。如果输入错误的用户名或密码,将返回错误信息。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值