背景
后面打算自己做一个独立产品,产品需要用到服务端渲染,而我比较擅长React,所以最近在学Next14。
刚开始学的时候,因为自己英文不好,中文文档又特别少,踩了不少坑。后面买了冴羽大佬的Next小册跟着学习,很快就上手了,小册质量很高,个人觉得很适合新手入门。
学完之后肯定是要实战的,这样才能把知识转化为自己的东西。所以使用next-auth库实现Github、Google、Gitee平台授权登录和账号密码登录来练练手,实现的过程中,也遇到了一些坑,下面给大家分享一下。
题外话
我前面有篇文章说tRPC和Next开发全栈应用很爽,那是我没用过Next的server action,在我写完这个登录demo后,才发现server action太香了,个人感觉tRPC在做前后端分离的后台管理项目还是挺有优势的,Next14中使用tRPC有些鸡肋了,因为server action已经足够好用了。
到网上搜索了一下,国外也有人讨论这个问题,这个帖子有很多人评论,大家可以看看。
原帖地址:www.reddit.com/r/nextjs/co…
需要挂代理才能访问
创建Next项目
使用下面命令创建项目,全部使用默认选项就行了。
npx create-next-app@latest
创建成功后,使用
npm run dev
启动项目,访问http://localhost:3000/,能看到下面页面表示启动成功。
引入shadcn-ui
ui框架使用最近很火的shadcn-ui
pnpm dlx shadcn-ui@latest init
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Stone
✔ Would you like to use CSS variables for colors? … no / yes
引入Button组件测试一下
pnpm dlx shadcn-ui@latest add button
改造app/page.tsx文件
import { Button } from '@/components/ui/button';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<Button>button</Button>
</main>
);
}
如下图,显示出按钮
黑色的按钮不好看,shadch-ui支持在线自定义主题,访问自定义主题页面, 选择一个主题,然后复制代码。
替换
app/global.css
中颜色变量,可以看到按钮颜色变了
在/app/component/ui目录下可以看到button组件,这就是shadcn库和别的ui库的区别,shadcn会把组件下载到本地,让使用者更方便的拓展。
引入next-auth
安装依赖
pnpmadd [email protected]
在src目录下创建auth.ts文件
// src/auth.tsimport NextAuth from'next-auth';exportconst{ handlers, auth, signIn, signOut }=NextAuth({
providers:[],});
在
src/app
目录下创建
api/auth/[...nextauth]/route.ts
文件
// src/app/api/auth/[...nextauth]/route.tsimport{ handlers }from"@/auth";exportconst{GET,POST}= handlers
实现github授权登录
到github注册授权应用
第一步
第二步
第三步
第四步
第五步
因为需要本地调试回调地址先写成http://localhost:3000 和 http://localhost:3000/api/auth/callback/github
第六步
第七步
记住clientId和clientSecret,马上要用。
创建.env文件
AUTH_GITHUB_ID=刚才的clientId
AUTH_GITHUB_SECRET=刚才的clientSecre
AUTH_SECRET=用于散列令牌、签署 cookie 和生成加密密钥的随机字符串。
AUTH_SECRET
可以使用
npx auth secret
命令生成
改造auth.ts文件,引入github provider
// src/auth.tsimport NextAuth from'next-auth';import Github from'next-auth/providers/github';exportconst{ handlers, auth, signIn, signOut }=NextAuth({
providers:[Github],})
使用form action登录github
// src/app/page.tsx
import { signIn } from '@/auth';
import { Button } from '@/components/ui/button';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<form
action={async () => {
'use server';
// 登录完成后,重定向到user页面
await signIn('github', { redirectTo: '/user' });
}}
>
<Button>github登录</Button>
</form>
</main>
);
}
有些人可能使用form action的时候,会报这个错,这是因为react版本太低了,form不支持绑定函数,需要升级react版本到18.2.0,react-dom到最新版本。
安装完,可能还会报错,重启一下vscode就行了。
这里的
use server
别忘记了,不然也会报错。
创建user页面
// src/app/user/page.tsx
import { auth } from '@/auth';
export default async function UserPage() {
// 从session中获取登录信息
const session = await auth();
return (
<div>
{session?.user ? (
<p>{JSON.stringify(session.user)}</p>
) : (
<p>未登录</p>
)}
</div>
)
}
注意
本地调试需要开代理并且还是全局代理或增强模式才能正常使用,如果没有梯子改本地hosts也可以。
sudovi /etc/hosts
把下面两行文本添加到
/etc/hosts
文件后面
20.205.243.168 api.github.com
140.82.121.3 github.com
加了这个后,不开代理也能快速访问github。
如果上面两个ip也能墙了,可以访问这个地址,寻找那些不超时的ip
效果展示
OAuth 2.0 授权流程
看完上面代码,大家是不是觉得github授权登录怎么那么简单,其实github授权登录流程还是挺复杂的,只是auth库帮我们实现了一些接口而已。
github是基于oAuth2授权登录流程,下面给大家说一下oAuth2授权登录流程。
OAuth 2.0 授权流程一般包括以下步骤:
- 用户访问应用:用户尝试登录或使用需要授权的应用。
- 应用请求授权:应用将用户重定向到授权服务器,并附带以下信息:客户端 ID(识别应用)、重定向 URI(成功授权后返回的地址)、响应类型(通常为 “code”,表示使用授权码流程)、范围(应用要求的权限列表)。
- 用户登录并认证:在授权服务器上,用户会被要求登录并确认应用请求的权限。
- 用户授权应用:用户在授权服务器上同意授予应用权限。
- 应用接收授权码:授权服务器将用户重定向回应用,同时在查询字符串中附带授权码。
- 应用请求访问令牌:应用通过后台服务向授权服务器发送包含授权码的请求,以换取访问令牌。同时还需要提供客户端 ID 和 客户端密钥。
- 应用接收访问令牌:授权服务器验证请求后,如果一切符合要求,就会发送访问令牌回应用。
- 应用使用访问令牌来访问保护资源:具有访问令牌的应用可以调用 API,访问用户数据或执行其他操作。
下面以github授权为例,给大家看一下具体授权过程
- 当前用户点击github登录按钮时,会跳转到github.com/login/oauth… 地址,并在query上携带下面参数
{// 需要获取的信息,这里是用户信息和邮箱scope:"read:user user:email",// 设置返回的数据是coderesponse_type:"code",// 在github上申请的client_idclient_id:"XXXXXXX",// 客户端生成的一种随机值(如一个随机字符串),通常在向授权服务器发出授权请求时发送。该值在转换前和转换后都需要保存,因为在后续的令牌交换过程中需要使用。主要用来防止授权码被截获并利用。code_challenge:"XXXXXXX",// 这是对 `code_challenge` 进行转换的方法,可以是 "plain" 或 "S256"// "plain": 对应的 `code_challenge` 就是原始的随机字符串。// "S256": 对应的 `code_challenge` 是原始随机字符串进行 SHA256 散列并进行 URL 安全的 Base64 编码后的结果。code_challenge_method:"plain",// 授权完成后,重定向地址redirect_uri:"http://localhost:3000/api/auth/callback/github"}
- 上一步授权成功后,会重定向上面配置的地址http://localhost:3000/api/auth/callback/github?code=8d4691082f7956265a78 , 并带上code。
- http://localhost:3000/api/auth/callback/github 接口里拿到code,调用github的 github.com/login/oauth… 接口获取token。
// 客户端的 ID
client_id:"XXXXX"// 客户端的密钥
client_secret:"XXXX",// 授权码
code:"8d4691082f7956265a78",// 前面code_challenge没加密前的随机字符串code_verifier:'XXXXX'
- 上一步拿到token后,调用api.github.com/user 接口获取用户信息,把上一步获取到的token放到请求头里。
基于上面流程,我用koa库实现了一下。
const Koa =require('koa');const Router =require('koa-router');const axios =require('axios');const crypto =require('crypto');const app =newKoa();const router =newRouter();// 你的 GitHub OAuth 应用配置constCLIENT_ID='XXXX';constCLIENT_SECRET='XXXX';const redirect_uri =`http://localhost:3000/api/auth/callback/github`;functiongenerateRandomString(){return crypto.randomBytes(64).toString('hex');}functionsha256(buffer){return crypto.createHash('sha256').update(buffer).digest();}functionbase64URLEncode(str){return str.toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');}
router.get('/login',ctx=>{const code_verifier =generateRandomString();const code_challenge =base64URLEncode(sha256(code_verifier));
ctx.cookies.set('code_verifier', code_verifier);const dataStr =(newDate()).valueOf();var path =`https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}&scope=user&state=${dataStr}&redirect_uri=${redirect_uri}&code_challenge=${code_challenge}&code_challenge_method=S256`;
ctx.redirect(path);});
router.get('/api/auth/callback/github',asyncctx=>{const code = ctx.query.code;const code_verifier = ctx.cookies.get('code_verifier');const params ={client_id:CLIENT_ID,client_secret:CLIENT_SECRET,code: code,code_verifier: code_verifier
};let res =await axios.post('https://github.com/login/oauth/access_token', params);
console.log(res ,'res');const access_token =decodeURIComponent(res.data.split('&')[0].split('=')[1]);
res =awaitaxios({url:'https://api.github.com/user',headers:{Authorization:`Bearer ${access_token}`},method:'POST',});
ctx.body = res.data;})
app.use(router.routes());
app.listen(3000);
Google登录
创建google应用
第一步
访问console.cloud.google.com/welcome 地址,创建项目
第二步
第三步
第四步
第五步
第六步
第七步
第八步
第九步
第十步
把自己的账号添加为测试人员
在.env文件中添加环境变量
GOOGLE_CLIENT_ID=上面CLIENT_ID
GOOGLE_CLIENT_SECRET=上面CLIENT_SECRET
改造auth.ts文件,添加google provider
import NextAuth from'next-auth';import Github from'next-auth/providers/github';import Google from'next-auth/providers/google';exportconst{ handlers, auth, signIn, signOut }=NextAuth({
providers:[
Github,Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET})],})
添加google登录按钮
效果展示
注意
这个必须要开全局代理才能使用,配置hosts不行,我试了很多ip都不行,有知道可以用的ip,评论区可以告知一下。
gitee授权登录
创建gitee授权应用
第一步
第二步
第三步
第四步
第五步
配置.env
GITEE_CLIENT_ID=上面的CLIENT_ID
GITEE_CLIENT_SECRET=上面的CLIENT_SECRET
自定义gitee prvider
auth是不支持gitee的,需要我们自定义provider,gitee授权登录使用的是标准的oAuth2协议,所以我们把github的provider复制出来改一下就行了。
// src/providers/gitee.ts/**
* @module providers/gitee
*/exportdefaultfunctionGitee(
config:any):any{const baseUrl ='https://gitee.com';const apiBaseUrl ='https://gitee.com/api/v5';return{
id:"gitee",
name:"Gitee",
type:"oauth",
authorization:{
url:`${baseUrl}/oauth/authorize`,
params:{ scope:''},},
token:{
url:`${baseUrl}/oauth/token`,
params:{
grant_type:'authorization_code',}},
userinfo:{
url:`${apiBaseUrl}/user`,asyncrequest({ tokens, provider }:any){const profile =awaitfetch(provider.userinfo?.url asURL,{
headers:{
Authorization:`Bearer ${tokens.access_token}`,"User-Agent":"authjs",},}).then(async(res)=>await res.json())if(!profile.email){const res =awaitfetch(`${apiBaseUrl}/user/emails`,{
headers:{
Authorization:`Bearer ${tokens.access_token}`,"User-Agent":"authjs",},})if(res.ok){const emails:any[]=await res.json()
profile.email =(emails.find((e)=> e.primary)?? emails[0]).email
}}return profile
},},profile(profile:any){return{
id: profile.id.toString(),
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,}},
options: config,}}
改造auth.ts文件,引入gitee provider
import NextAuth from'next-auth';import Github from'next-auth/providers/github';import Google from'next-auth/providers/google';import Gitee from'./providers/gitee';exportconst{ handlers, auth, signIn, signOut }=NextAuth({
providers:[
Github,Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,}),Gitee({
clientId: process.env.GITEE_CLIENT_ID,
clientSecret: process.env.GITEE_CLIENT_SECRET,}),],})
添加gitee登录按钮
效果演示
点击gitee登录,可以正常获取gitee用户信息
注意
gitee因为是国内的,不要开代理也可以正常使用。
引入prisma,把用户登录信息持久化到数据库
上面我们实现了授权登录,但是哪些用户登录了,我们并不知道,所以需要把用户信息持久话到数据库中。数据库我这边使用mysql,数据库操作库使用prisma。
安装mysql
我本地使用Docker Desktop启动mysql服务。具体教程可以看下我以前的文章。
安装prisma
prisma也是最近比较热门的一个orm库,主要数据库迁移比较简单,使用也比较简单。
当然你也可以使用其他orm库,auth.js主流orm库都支持。
安装依赖
pnpmadd prisma -Dpnpmadd @auth/prisma-adapter
生成prisma配置
npx prisma init --datasource-provider mysql
修改.env文件,设置数据库连接
设置auth需要的模型
把下面模型配置复制到
prisma/schema.prisma
文件中
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
把模型生成到数据库中
npx prisma migrate dev --name init
生成prisma客户端
npx prisma generate
改造auth.ts文件,引入适配器
// src/auth.tsimport{ PrismaAdapter }from'@auth/prisma-adapter';import{ PrismaClient }from'@prisma/client';import NextAuth from'next-auth';import Github from'next-auth/providers/github';import Google from'next-auth/providers/google';import Gitee from'./providers/gitee';const prisma =newPrismaClient();exportconst{ handlers, auth, signIn, signOut }=NextAuth({
adapter:PrismaAdapter(prisma),
providers:[
Github,Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,}),Gitee({
clientId: process.env.GITEE_CLIENT_ID,
clientSecret: process.env.GITEE_CLIENT_SECRET,}),],})
这里如果引入
@prisma/client
报错,重新执行一下
npx prisma generate
,执行完如果还报错,重启一下vscode。
测试一下
使用google授权登录一下,使用
npx prisma studio
命令启动一个数据库客户端,可以在线查看数据库表数据,也可以使用数据库连接工具查看。
因为刚才登录了一下,已经有了一条数据。
这里注意一下,如果没有退出登录,直接在用gitee登录一下,即使两个邮箱不一样,也不会生成两个用户,而是给当前用户添加一个账号。
我没退出登录又登录了一次gitee,就变成这样了,下面我们实现一下退出登录功能。
实现退出登录
改造/user/page.tsx,添加退出登录功能
// src/app/user/page.tsx
import { auth, signOut } from '@/auth';
import { Button } from '@/components/ui/button';
export default async function UserPage() {
// 从session中获取登录信息
const session = await auth();
return (
<div>
{session?.user ? (
<>
<p>{JSON.stringify(session.user)}</p>
<form action={async () => {
'use server';
// 退出登录后,重定向首页
await signOut({ redirectTo: '/' });
}}>
<Button>退出登录</Button>
</form>
</>
) : (
<p>未登录</p>
)}
</div>
)
}
登录完github,然后退出登录,登录gitee,这时候就会生成两条用户数据了。
自定义session数据
可以看到从session中取出来的用户信息没有id,但是前端很多地方可能需要用到用户id,这就需要自定义session返回数据了。
改造auth.ts文件,先设置生成session策略为jwt,然后再callbacks中添加jwt和session方法,在session方法中可以自定义session里的内容。
这里有个坑,网上很多教程是这样写的。
我试了一下,user一直是空,根本没法用,可以从token的sub字段中取到useId。
再次登录一下,发现id正常返回了
处理登录异常情况
授权登录如果有报错,系统会默认重定向到
/api/auth/signin
内置页面,我们想重定向自己的页面,可以在auth.ts中配置。
我现在使用google授权登录,因为邮箱和github是同一个,而用户邮箱不能重复,所以会报错,报错后会重定向到首页,并带上错误码。
服务端组件中可以直接从
searchParams
取url上的参数,错误码对应的错误详细信息可以到官网查看
实现邮箱密码登录
登录页面
shadcn-ui有form组件,不过需要配合zod和react-hook-form一起使用。
安装form和input组件
npx shadcn-ui@latest add form input
安装react-hook-form和zod
pnpmadd react-hook-form zod
代码实现
在app文件夹下创建auth文件夹,后面登录相关的页面都放在这个文件夹下。在auth文件夹下创建
login/page.tsx
文件,再把下面代码复制进去,下面代码是shadcn提供的实例代码。
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})
export default function LoginPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
}
return (
<div className='flex h-screen justify-center items-center'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-[400px]">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</div>
)
}
访问http://localhost:3000/auth/login
改造页面
前几天看到一个炫酷的登录页面,我在码上掘金上实现了一下,大家可以看看效果。
把这个引入到项目中,并封装成组件
// src/components/colorful-card/index.tsx
import React from 'react';
import './index.css';
export default function ColorfulCard({ children }: { children: React.ReactElement }) {
return (
<div className="colorful">
<div className="box bg-[#17171a]">
<div className="box-mask bg-[#28292d]" />
</div>
<div className="content bg-[#28292d]">
{children}
</div>
</div>
)
}
// src/components/colorful-card/index.css
.colorful{position: relative;}.colorful .box{border-radius: 8px;position: absolute;top: 0;bottom: 0;left: 0;right: 0;overflow: hidden;}.colorful .box::before{content:'';position: absolute;top: -50%;left: -50%;bottom: 50%;right: 50%;transform-origin: bottom right;background:linear-gradient(0deg, transparent, #6960EC, #6960EC);animation: animate 6s linear infinite;}.colorful .box::after{content:'';position: absolute;top: -50%;left: -50%;bottom: 50%;right: 50%;background:linear-gradient(0deg, transparent, #6960EC, #6960EC);transform-origin: bottom right;animation: animate 6s linear infinite;animation-delay: -3s;}@keyframes animate{0%{transform:rotate(0deg);}100%{transform:rotate(360deg);}}.colorful .box-mask{position: absolute;z-index: 10;inset: 3px;border-radius: 8px;}.colorful .content{border-radius: 8px;position: relative;z-index: 11;margin: 3px;}
因为这个适合在暗色主题,所以我们给主题切换成暗色,创建
theme-provider.tsx
,需要先安装
next-themes
pnpmadd next-themes
// src/components/providers/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
改造layout.tsx文件,设置主题为dark。
import { ThemeProvider } from '@/components/providers/theme-provider';
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="bg-[#23242a]">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
启动项目,控制台会有报错
给html添加这个属性就行了
改造auth/login/page.tsx代码,把上面写的
ColorfulCard
组件引入进去
"use client"
import { IconGiteefillround } from '@/assets/icons/gitee-fill-round'
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Separator } from '@/components/ui/separator'
import { GithubOutlined, GoogleOutlined } from '@ant-design/icons'
import Link from 'next/link'
import ColorfulCard from '@/components/colorful-card'
const loginFormSchema = z.object({
email: z.string().email({
message: "无效的邮箱格式",
}),
password: z.string().min(1, {
message: "不能为空",
}),
})
export type loginFormSchemaType = z.infer<typeof loginFormSchema>;
export default function LoginPage() {
const form = useForm<loginFormSchemaType>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: '',
password: '',
}
})
async function onSubmit(values: loginFormSchemaType) {
console.log(values);
}
return (
<div className='py-[100px] flex justify-center'>
<ColorfulCard>
<Form {...form}>
<div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="p-[20px] w-[420px]"
>
<div className='flex justify-center my-[20px]'>
<h1 className='text-2xl font-bold text-[#6960EC]'>登录</h1>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className='mt-[20px]'>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
</FormItem>
)}
/>
<div className='flex justify-between mt-[20px]'>
<Button size='lg' className='w-full' type="submit">
登录
</Button>
</div>
<Separator className="my-4 bg-[#666]" />
<div className='flex flex-col gap-3'>
<div className='flex gap-2'>
<Button
size='lg'
variant='secondary'
type="button"
className='px-0 flex-1 flex justify-center gap-1'
>
<GithubOutlined />GitHub登录
</Button>
<Button
size='lg'
variant='secondary'
type="button"
className='px-0 flex-1 flex justify-center gap-1'
>
<GoogleOutlined /> Google登录
</Button>
<Button
size='lg'
variant='secondary'
type="button"
className='px-0 flex-1 flex justify-center gap-1'
>
<IconGiteefillround /> Gitee登录
</Button>
</div>
<Link href="/auth/register">
<Button size='lg' variant='link' className='w-full mt-[12px]' type='button'>
还没有账号?注册新用户
</Button>
</Link>
</div>
</form>
</div>
</Form>
</ColorfulCard>
</div>
)
}
这里图标使用的是antd的,也可以使用我以前写的插件使用iconfont里的图标。
颜色搭配不太好,修改一下按钮和input组件的颜色,可以修改global.css里的颜色变量,修改dark模式下的颜色变量。
.dark{--background: 222.2 84% 4.9%;--foreground: 210 40% 98%;--card: 222.2 84% 4.9%;--card-foreground: 210 40% 98%;--popover: 222.2 84% 4.9%;--popover-foreground: 210 40% 98%;--primary: 244 79% 65%;--primary-foreground: 222.2 47.4% 100%;--secondary: 221.2 70.2% 50%;--secondary-foreground: 210 40% 98%;--muted: 217.2 32.6% 17.5%;--muted-foreground: 215 20.2% 65.1%;--accent: 217.2 32.6% 17.5%;--accent-foreground: 210 40% 98%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 210 40% 98%;--border: 217.2 32.6% 17.5%;--input: 0 0% 40%;--ring: 244 79% 65%;}
这样看起来,好看了一些
登录功能
因为login/page.tsx是客户端组件,不能直接定义server action,所以我们单独定义一个action.ts文件放在login文件夹下。
实现三方授权登录
actiont.ts实现
// src/app/auth/login/action.ts'use server';import{ signIn }from'@/auth';exportconstloginWithGithub=async()=>{awaitsignIn('github',{
redirectTo:'/user',});};exportconstloginWithGoogle=async()=>{awaitsignIn('google',{
redirectTo:'/user',});};exportconstloginWithGitee=async()=>{awaitsignIn('gitee',{
redirectTo:'/user',});};
按钮点击事件调用server action方法
实现邮箱密码登录
先给用户模型添加password字段
添加完成后,执行下面命令同步到数据库
npx prisma db push
npx prisma generate
在action.ts中添加自定义凭证登录
...exportconst loginWithCredentials =async(
credentials: LoginFormSchemaType
):Promise<void|{error?:string}>=>{try{awaitsignIn('credentials',{...credentials,
redirectTo:'/user',});}catch(error){if(error instanceofAuthError){return{
error:'用户名或密码错误',};}// 这里一定要抛出异常,不然成功登录后不会重定向throw error;}};
如果登录失败,我们用shadcn的Toast组件把错误消息弹出来,安装toast组件
npx shadcn-ui@latest add toast
把Toaster组件放到layout.tsx中
在表单的
onSubmit
事件中调用l
oginWithCredentials
方法,并把当前输入的邮箱和密码传过来,如果error有值说明登录失败,把错误消息弹出来。
改造auth.ts文件,添加
Credentials
登录,并且实现登录校。如果返回null,表示登录失败。prisma实例后面会在多个地方使用,这里给prisma单独放到一个文件里然后导出。
// src/lib/prisma.tsimport{ PrismaClient }from'@prisma/client';exportconst prisma =newPrismaClient();
如果user中没有password属性,重启一下vscode就行了。
手动添加一个用户
登录失败的情况
登录成功的情况
实现注册功能
注册页面实现
和登录页面差不多,只是多了个字段。要注意的是,在客户端组件中跳转路由使用
useRouter
方法。
// src/app/auth/register/page.tsx
"use client"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { ReloadIcon } from '@radix-ui/react-icons'
import { useForm } from "react-hook-form"
import * as z from "zod"
import ColorfulCard from '@/components/colorful-card'
import { useToast } from '@/components/ui/use-toast'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { register } from './action'
const registerFormSchema = z.object({
username: z.string().min(1, {
message: "不能为空",
}),
password: z.string().min(1, {
message: "不能为空",
}),
email: z.string().email({ message: "无效的邮箱格式" }),
})
export type RegisterFormSchemaType = z.infer<typeof registerFormSchema>;
export default function RegisterPage() {
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const form = useForm<RegisterFormSchemaType>({
resolver: zodResolver(registerFormSchema),
defaultValues: {
email: '',
username: '',
password: '',
},
})
// 2. Define a submit handler.
async function onSubmit(values: RegisterFormSchemaType) {
setLoading(true);
const result = await register(values);
if (result?.error) {
toast({
title: '注册失败',
description: result.error,
variant: 'destructive',
});
} else {
// 注册成功,跳到登录页面
router.push('/auth/login');
}
setLoading(false);
}
return (
<div className='py-[100px] flex justify-center'>
<ColorfulCard>
<Form {...form}>
<div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="p-[20px] w-[420px]"
>
<div className='flex justify-center my-[20px]'>
<h1 className='text-2xl font-bold text-[#6960EC]'>注册</h1>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>用户名</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className='mt-[20px]'>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className='mt-[20px]'>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-between mt-[30px]'>
<Button disabled={loading} size='lg' className='w-full' type="submit">
{loading && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}
注册
</Button>
</div>
<div className='flex flex-col gap-3'>
<Link href="/auth/login">
<Button disabled={loading} size='lg' variant='link' className='w-full mt-[12px]' type='button'>
已有账号?返回登录
</Button>
</Link>
</div>
</form>
</div>
</Form>
</ColorfulCard>
</div>
)
}
实现注册方法
需要安装bcrypt库,对密码进行加盐处理。
pnpmadd bcrypt
pnpmadd @types/bcrypt -D
// src/app/auth/register/action.ts'use server';import{ prisma }from'@/lib/prisma';import bcrypt from'bcrypt';import{ RegisterFormSchemaType }from'./page';exportconstregister=async(data: RegisterFormSchemaType)=>{const existUser =await prisma.user.findUnique({
where:{
email: data.email,},});if(existUser){return{
error:'当前邮箱已存在!',};}// 给密码加盐,密码明文存数据库不安全const hashedPassword =await bcrypt.hash(data.password,10);await prisma.user.create({
data:{
name: data.username,
password: hashedPassword,
email: data.email,},});};
测试一下,可以看到密码已经加密了
改造登录校验方法,因为数据库中密码已经加密了,不能使用用户输入的密码直接对比。
使用刚才注册的邮箱登录
实现邮箱验证
为了验证用户注册时的邮箱是不是真实的邮箱,一般网站会在用户注册成功之后,给当前注册邮箱发送一条激活当前账号的邮件,邮件中有一个链接,用户点击链接可以激活邮箱。下面我们来实现一下这个功能。
开始之前需要有一个邮箱服务器给用户发送邮箱,可以使用自己的邮箱当邮箱服务器,可以看下我这篇文章开启邮箱服务。
安装nodemailer依赖
pnpmadd nodemailer
pnpmadd @types/nodemailer -D
配置邮箱环境变量
在.env中添加邮箱配置
MAIL_USER:表示邮箱账号
MAIL_PASS:表示上面开启服务时的密钥,不是登录密码。
封装公共邮箱发送方法
// src/lib/email.ts
import * as nodemailer from 'nodemailer';
export interface MailInfo {
// 目标邮箱
to: string;
// 标题
subject: string;
// 文本
text?: string;
// 富文本,如果文本和富文本同时设置,富文本生效。
html?: string;
}
export const sendEmail = async (mailInfo: MailInfo) => {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: +(process.env.MAIL_PORT || 465),
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
});
// 定义transport对象并发送邮件
const info = await transporter.sendMail({
from: `next-auth-demo <${process.env.MAIL_USER}>`,
...mailInfo,
});
return info;
};
注册成功后,发送邮件
随机token使用uuid生成的,所以需要安装uuid依赖
pnpmadd uuid
pnpmadd @types/uuid -D
添加一个注册成功后显示消息的页面
注册成功后,因为需要等用户激活邮箱,所以不能直接跳转到登录页面,停留在当前页面也不太好,所以我们单独做一个页面。
// src/app/auth/register/result/page.tsx
import { IconQingzhu } from '@/assets/icons/qingzhu'
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export default function RegisterResultPage() {
return (
<div className='mt-[20px] px-[100px]'>
<Alert>
<AlertTitle className='flex items-center'>
<IconQingzhu className='text-green-500' />
注册成功!
</AlertTitle>
<AlertDescription>
您的验证邮件已发送,请前往验证。
<Link href="/auth/login">
<Button color="green" variant="link">
已验证?返回登录
</Button>
</Link>
</AlertDescription>
</Alert>
</div>
)
}
添加激活页面
用户点击激活链接,会跳转到系统中激活页面,在激活页面中拿到token,通过token拿到用户邮箱,通过用户邮箱拿到用户信息,然后把用户信息中emailVerified属性设置为当前时间,表示为已激活。
// src/app/auth/activate/page.tsx
'use client'
import { IconQingzhu } from '@/assets/icons/qingzhu'
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from '@/components/ui/button'
import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { activateUser } from './action'
export default function ActivatePage() {
const params = useSearchParams();
const token = params.get('token');
const [error, setError] = useState('');
const [success, setSucess] = useState('');
const activate = useCallback(() => {
setError('');
setSucess('');
if (!token) {
return;
}
activateUser(token).then((result) => {
if (result?.error) {
setError(result.error);
}
if (result?.success) {
setSucess(result.success);
}
});
}, [token])
useEffect(() => {
activate();
}, [activate]);
if (!error && !success) {
return (
<div className='mt-[20px] px-[100px]'>
<Alert>
<AlertTitle className='flex items-center gap-2'>
<LoadingOutlined />
激活中...
</AlertTitle>
</Alert>
</div>
)
}
if (success) {
return (
<div className='mt-[20px] px-[100px]'>
<Alert>
<AlertTitle className='flex items-center'>
<IconQingzhu className='text-green-500' />
{success}
</AlertTitle>
<AlertDescription>
<Link href="/auth/login">
<Button color="green" variant="link">
返回登录
</Button>
</Link>
</AlertDescription>
</Alert>
</div>
)
}
return (
<div className='mt-[20px] px-[100px]'>
<Alert>
<AlertDescription className='flex items-center gap-2'>
<ExclamationCircleOutlined className='text-red-700' />
{error}
</AlertDescription>
</Alert>
</div>
)
}
// src/app/auth/activate/action.ts
'use server';
import { prisma } from '@/lib/prisma';
export const activateUser = async (token: string) => {
const verificationToken = await prisma.verificationToken.findUnique({
where: {
token,
},
});
if (!verificationToken || verificationToken.expires < new Date()) {
return {
error: '当前连接已失效',
};
}
const user = await prisma.user.findUnique({
where: {
email: verificationToken.identifier,
},
});
if (!user) {
return {
error: '激活失败,请联系管理员',
};
}
await prisma.verificationToken.delete({
where: {
token: verificationToken.token,
},
});
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
return {
success: '激活成功',
};
};
改造登录方法,用户没有激活不让登录
效果展示
注册
注册完成后
没有激活直接登录
收到的邮件
激活成功
再次登录就可以登录成功了
中间件
需求分析
上面我们实现了登录功能,现在想一下,如果用户没有登录的情况下,访问/user页面时系统给重定向到登录页面。
这个需求最简单的实现方案是在user页面,判断是否登录,没有登录就给跳转到登录页面,但是以后有很多页面都需要登录才能访问,不是要写很多遍吗,所以这个需求我们使用中间件来实现。
具体实现
在src目录下创建
middleware.ts
文件,我开始看的有个教程说在项目根目录下创建,试了一下,根本不会执行,放到src目录下可以正常运行。
// src/middleware.tsimport NextAuth from'next-auth';const{auth}=NextAuth({
providers:[],});exportdefaultauth((req)=>{const isLoggedIn =!!req.auth?.user;// 没有登录,并且访问的页面不是以auth开头的,则重定向到登录页if(!isLoggedIn &&!req.nextUrl.pathname.startsWith('/auth')){return Response.redirect(newURL('/auth/login', req.nextUrl));}elseif(isLoggedIn && req.nextUrl.pathname.startsWith('/auth')){// 已经登录并且访问的页面是以auth开头的,则重定向到用户页,不需要重新登录了return Response.redirect(newURL('/user', req.nextUrl));}});// 排除掉一些接口和静态资源exportconst config ={
matcher:['/((?!api|_next/static|_next/image|favicon.ico).*)'],};
再加一个需求,当用户访问根页面时重定向到用户信息页面,这个可以在根目录下的
next.config.mjs
中配置重定向。
/** @type {import('next').NextConfig} */const nextConfig ={redirects:async()=>{return[{source:"/",destination:"/user",permanent:true,}];},};exportdefault nextConfig;
客户端组件中获取session
以前在Next Pages模式下客户端组件可以使用SessionProvider和useSession方法获取session,但是在app router模式下,不建议这样使用了,那我们就想在客户端组件中使用session怎么办,可以在服务器组件中获取session,然后把值当成属性传给客户端组件。
服务器组件
import { auth } from '@/auth';
import Client from './client';
export default async function TestPage() {
const session = await auth();
return (
<Client session={session} />
)
}
客户端组件
'use client'
export default function Client({
session
}: {
session: any
}) {
return (
<div>{session?.user && (
<p>{JSON.stringify(session.user)}</p>
)}</div>
)
}
项目部署
解决build报错问题
部署之前本地先运行一下
npm run build
,build一下试试,因为有些报错
npm run dev
能正常运行,build的时候会报错。
比如现在这个报错,刚才我们在开发环境能正常运行,build报错了。报错的意思是
src/app/auth/activate/page.tsx
页面中使用
useSearchParams
必须要用
Suspense
包起来。
把
src/app/auth/activate/page.tsx
文件里的内容拆到另外一个文件中,然后在page文件中把ActivateClient组件用Suspense包起来。
import { Suspense } from 'react'
import ActivateClient from './client'
export default function ActivatePage() {
return (
<Suspense>
<ActivateClient />
</Suspense>
)
}
docker部署
我这里部署使用了github action和docker compose方案,这套部署方案细节可以看下我以前写的一篇文章。
docker部署官方给了一个demo仓库,把仓库中Dockerfile复制到本地,在build前添加一行生成prisma客户端,其他都不用改。
然后修改
next.config.mjs
配置文件
在项目根目录下创建
.github/workflows/docker-publish.yml
文件
name: Docker
# This workflow uses actions that are not certified by GitHub.# They are provided by a third-party and are governed by# separate terms of service, privacy policy, and support# documentation.on:push:branches:['main']# Publish semver tags as releases.tags:['v*.*.*']env:REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}jobs:build:runs-on: ubuntu-latest
permissions:contents: read
packages: write
# This is used to complete the identity challenge# with sigstore/fulcio when running outside of PRs.id-token: write
steps:-name: Checkout repository
uses: actions/checkout@v3
# Workaround: https://github.com/docker/build-push-action/issues/461-name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
-name: Cache Docker layers
uses: actions/cache@v2
with:path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}restore-keys:|
${{ runner.os }}-buildx-# Login against a Docker registry except on PR# https://github.com/docker/login-action-name: Log into registry ${{ env.REGISTRY }}if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:registry: ${{ env.REGISTRY }}username: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}# Extract metadata (tags, labels) for Docker# https://github.com/docker/metadata-action-name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}# Build and push Docker image with Buildx (don't push on PR)# https://github.com/d˜ocker/build-push-action-name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:context: .
push: ${{ github.event_name != 'pull_request' }}tags: ${{ steps.meta.outputs.tags }}labels: ${{ steps.meta.outputs.labels }}cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
-name: Move cache
run:|
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
因为.env文件存放我们的密钥信息,所以把这个文件忽略掉,不要上传到仓库。
在根目录添加.env.production文件,配置服务的域名,线上环境授权登录不能使用ip和端口号,必须使用域名。
AUTH_URL=https://auth.fluxyadmin.cn
服务器上使用docker-compose部署,docker-compose文件如下
version:'3.7'services:auth:image: ghcr.io/dbfu/next-auth-demo:main
container_name: next
restart: unless-stopped
environment:DATABASE_URL:AUTH_GITHUB_ID:AUTH_GITHUB_SECRET:AUTH_SECRET:GOOGLE_CLIENT_ID:GOOGLE_CLIENT_SECRET:GITEE_CLIENT_ID:GITEE_CLIENT_SECRET:MAIL_HOST:MAIL_PORT:MAIL_USER:MAIL_PASS:networks:- app_subnet
ports:- 5001:5001extra_hosts:-"github.com:140.82.121.3"-"api.github.com:20.205.243.168"
把本地的环境变量复制到docker-componse文件中。因为我的云服务器是腾讯云,国内云服务是不能访问github的,所以需要配置hosts。国内服务器没办法访问google,所以没办法使用google授权登录。除非给服务器开梯子,或者买国外服务器。
更改授权登录地址
前面我们授权地址配的是本地地址,服务发布成功后,把地址改成域名。
最后
到此,从开发到部署整个流程结束了,大家可以使用下面地址去体验一下,不支持google登录。
demo体验地址:auth.fluxyadmin.cn/auth/login
仓库地址:github.com/dbfu/next-a…
下篇准备分享一个next实战项目,敬请期待。
版权归原作者 前端小付 所有, 如有侵权,请联系我们删除。