开始之前
在开始这趟旅程之前,我建议你对JavaScript和TypeScript有基本的了解,并且熟悉React和Next. js框架,由于鉴权系统的内容很多,知识点也多,本节只讲到最基础的登陆注册功能的,后续的未来再展开
设置项目
首先,我们需要使用next的提供的脚手架生成一个简单模版
npx create-next-app@latest
next的脚手架会让你选一大堆东西,基本上所有东西都按照默认选择就行,除了App Router在我看来是必须的,其他的因人而异。
如果一切正常的话,脚手架已经创建好了一个最基础的项目结构,就像这样:
OK,让我们开始给它装一下包,用啥都行,npm、yarn、pnpm,我这里用的是pnpm,因为这些都不重要,我们快速跳过,执行命令:
pnpm i
pnpm dev
让我们访问localhost:3000,这样,我们就获得了一个基础next项目
真不错!
了解Next. js中的身份验证
在Next.js中,实现鉴权是非常方便的,原因是Next.js给我们提供了非常强大的能力,follow me,让我们一步一步实现Next.js中的鉴权系统。
在这开始之前,我们稍微提一句,鉴权系统是干什么的?
简单来说,就是网页需要知道你是谁,软件需要根据你的信息,提供相应的服务。
在b站上,如果你没有登录,你会看到b站推你给你一些常见的视频,如果你登录后,b站会根据你的浏览记录,给你推更多你喜欢的视频,当然,如果你是大会员,那么你还可以选择更加高清的视频,比如原画、蓝光。
好,回到我们鉴权的系统,常见的登录模式就是通过 用户 + 密码的方式来登录的,当然,随着移动设备的兴起,手机验证码登录,第三方登录如qq登录、微信登录也越来越流行,不过没关系,不管怎么登录都不影响我们鉴权系统的设计。
使用Prisma创建用户模型
首先,让我们设计一个简单用户模型,目前社区有很多很好用的orm框架,这些框架简直就是前端工程师的福音,因为可以先不去学习sql语句,就按照正常方法调用就行,这里我是用的orm框架是prisma
我们先使用prisma来初始化一个项目
pnpm add install prisma -D
npx prisma init
OK,如果一切正常的话,你可以在文件目录中找到primsa的文件夹,prisma推荐使用的数据库是postgresql,你可以在网上找资源去安装这个数据库,当然,你也可以用比较常见的mysql数据库。
设计一个简单的user model
model User {
id String @id @default(uuid())
username String @unique
password String
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
让我们把这个模型复制到你的 prisma/shcema.prisma 中,现在它长这样
// This is your Prisma schema file,
// learn more about it in the docs: <https://pris.ly/d/prisma-schema>
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: <https://pris.ly/cli/accelerate-init>
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
password String
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
紧接着,我们调用prisma generate prisma migrate dev 在数据库中创建
npx prisma generate
npx prisma migrate dev --name=init
如果你没做什么的话,目前应该会报错了,它会给你报权限错误,无法连接数据库,这个时候,我们就需要打开.env文件去修改一下连接数据库的账号和密码了,我的就改成这样了
# .env
DATABASE_URL="postgresql://postgres:12345678@localhost:5432/auth-example?schema=public"
Ok,让我们再一次试一下,执行npx prisma migrate dev --name=init
到这里我们已经可以操作数据库了,一行sql语句都不用写,cool
另外,考虑大家都没有数据库可视化的软件(毕竟有些软件要钱嘛),prisma能够极为便捷的启动一个服务,帮助我们开发调试数据库,执行命令
npx prisma studio --port 8888
然后访问对应的服务,你就能看到prisma给你准备的页面啦
好的,前期的准备工作已经准备完毕了,开始写页面吧
第一个页面,login
让我们创建一个login文件夹,并且里面添加一个page.tsx文件
next.js基础相关的,我默认不在这里提及,如想了解,请自行查看next.js官方文档
就像这样:
然后,让我们搞一个简单的 填写用户 + 密码的表单,让我们在这个page里写一个简单的表单
// app/login/page.tsx
export default function LoginPage() {
return (
<div>
<h1>Login</h1>
<form>
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
</div>
);
}
OK,我们实现了一个非常基础的表单,没关系,我们后面再慢慢完善它
额,确实太难看了,我们简单润色一下
// app/login/page.tsx
export default function LoginPage() {
return (
<div className="flex flex-col items-center justify-center h-screen space-y-5">
<h1 className="text-2xl font-bold">Login</h1>
<form className="flex flex-col items-center justify-center space-y-5">
<input className="border border-gray-300 rounded-md p-2" type="text" name="username" placeholder="Username" />
<input className="border border-gray-300 rounded-md p-2" type="password" name="password" placeholder="Password" />
<button className="bg-blue-500 text-white p-2 rounded-md" type="submit">Login</button>
</form>
</div>
);
}
看起来不错,让我们继续
login ServerAction的思考以及实现
让我们梳理一下,当我们输入内容,点击提交时会发生什么
好的,让我们简单的实现一下
验证部分,我们可以借助zod,yup这类校验库,这里我是用的zod,来,让我们安装一下
pnpm add zod
然后,我们来简单实现一些基本逻辑,在创建一个action.ts,目录结构为app/login/action.ts
"use server";
import z from "zod";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function loginAction(formData: FormData) {
"use server";
const username = formData.get("username");
const password = formData.get("password");
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username: parsed.data.username },
});
if (!user) {
return { error: "User not found" };
}
if (user.password !== parsed.data.password) {
return { error: "Invalid password" };
}
return { success: true };
}
OK,让我们把这个action放在我们的组件中使用
// /src/app/login/page.tsx
"use client"; // 因为我们在onSubmit里面使用了函数,rsc无法序列函数,所以这里将组件切换成了客户端组件
import { loginAction } from "./action";
export default function LoginPage() {
return (
<div className="flex flex-col items-center justify-center h-screen space-y-5">
<h1 className="text-2xl font-bold">Login</h1>
<form
className="flex flex-col items-center justify-center space-y-5"
onSubmit={async (ev) => {
ev.preventDefault();
const result = await loginAction(new FormData(ev.currentTarget));
console.log(result);
}}
>
<input className="border border-gray-300 rounded-md p-2" type="text" name="username" placeholder="Username" />
<input className="border border-gray-300 rounded-md p-2" type="password" name="password" placeholder="Password" />
<button className="bg-blue-500 text-white p-2 rounded-md" type="submit">Login</button>
</form>
</div>
);
}
当我们提交一个正常数据时,控制台会打印"User not found”
当什么都不填写时,控制台会打印"Invalid username or password”
很棒,但是我们也注意到了我们的问题,我们目前还没有一个真正的用户
创建我们的第一个用户吧,register
好的,让我们继续在项目中创建一个新的路由,创建一个register文件夹,并且在内部创建一个page.tsx、和action.ts文件,就像这样
让我在page.tsx中简单完成一个表单,直接从登陆那边复制一份过来,改改就能用了
app/register/page.tsx
"use client";
export default function RegisterPage() {
return (
<div className="flex flex-col items-center justify-center h-screen space-y-5">
<h1 className="text-2xl font-bold">Register</h1>
<form
className="flex flex-col items-center justify-center space-y-5"
>
<input className="border border-gray-300 rounded-md p-2 text-neutral-700" type="text" name="username" placeholder="Username" />
<input className="border border-gray-300 rounded-md p-2 text-neutral-700" type="password" name="password" placeholder="Password" />
<input className="border border-gray-300 rounded-md p-2 text-neutral-700" type="email" name="email" placeholder="Email" />
<button className="bg-blue-500 text-white p-2 rounded-md" type="submit">Register</button>
</form>
</div>
);
}
register ServerAction的思考以及实现
那么让我们来思考一下,注册的逻辑是什么样子的呢?
我们简单完善一下server action
// app/register/action.ts
"use server";
import { prisma } from "@/db/prisma";
import z from "zod";
const registerSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
email: z.string().email(),
});
export const registerAction = async (formData: FormData) => {
const username = formData.get('username')?.toString() ?? '';
const password = formData.get('password')?.toString() ?? '';
const email = formData.get('email')?.toString() ?? '';
if(!registerSchema.safeParse({ username, password, email }).success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username },
})
if(user) {
return { error: "User already exists" };
}
// 嘿,这地方需要加密密码哦
await prisma.user.create({
data: { username, password, email },
})
return { success: true };
}
是的,为了用户数据的安全,我们不能直接将用户的密码明文存储在数据库中,因此这里需要通过一些库进行加密,这里我建议使用bcrypt这样的库来进行密码散列
好的,让我们引入这个库吧
pnpm add bcryptjs
pnpm add @types/bcryptjs -D
让我们简单重构一下代码
"use server";
import { prisma } from "@/db/prisma";
import z from "zod";
import bcrypt from "bcryptjs";
const registerSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
email: z.string().email(),
});
export const registerAction = async (formData: FormData) => {
const username = formData.get('username')?.toString() ?? '';
const password = formData.get('password')?.toString() ?? '';
const email = formData.get('email')?.toString() ?? '';
if(!registerSchema.safeParse({ username, password, email }).success) {
return { error: "Invalid username or password" };
}
const existingUser = await prisma.user.findFirst({
where: {
OR: [
{ username },
{ email }
]
}
});
if(existingUser) {
return {
error: existingUser.username === username
? "username already exists"
: "email already exists"
};
}
// 嘿,这地方需要加密密码哦, 这里的环境变量 process.env.BCRYPT_SALT = 10
try {
await prisma.user.create({
data: { username, email, password: bcrypt.hashSync(password, Number(process.env.BCRYPT_SALT)) },
})
} catch (error) {
return { error: (error as Error).message };
}
return { success: true };
}
试试看,我已经注册了一个账号在我数据库里了,它看起来很不错
OK,我们回到我们的login server action,我想我们要优化一下密码的对比了
"use server";
import z from "zod";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/db/prisma";
import bcrypt from "bcryptjs";
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function loginAction(formData: FormData) {
const username = formData.get("username");
const password = formData.get("password");
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username: parsed.data.username },
});
if (!user) {
return { error: "User not found" };
}
// 这里修改成 bcrypt.compareSync 来比较密码
if (!bcrypt.compareSync(parsed.data.password, user.password)) {
return { error: "Invalid password" };
}
return { success: true };
}
OK,快用你刚刚创建的账号登陆吧,我已经登陆成功回到首页了
当然,这里面还有很多可以优化的地方,不过没关系,我们后续都会讲到,现在你可以去掉杯水,放松一下
使用jwt来存储用户的登陆状态
看起来玄星啊已经成功通过了我程序的校验了,好吧,我现在承认,你就是玄星啊,现在我要给你颁发一个令牌了,表明在某一段时间内,你可以在我的程序中畅通无阻的使用各种功能
关于这个令牌的实现,其实有很多方案,常见的方案有:
- session
- JWT
那么什么是JWT方案呢?
简单来说,就是你在http请求头中,添加鉴权的信息,这个信息是一长串加密后的用户信息,后端每一次处理你的请求时,都会从请求头中获取到这个信息,用特定的解密方案来解开加密,获取里面的数据,然后与实际数据库的数据做对比,如果对比成功,则有权限进行访问,如果不成功则拒绝访问。
它的好处:
- 无状态,自身包含了身份验证所需的所有信息,因此,服务器上不会需要存储session信息。这增加了系统的可用性和伸缩性,大大减轻了服务端的压力
- 有效避免了CSRF攻击,使用JWT进行身份验证不需要依赖Cookie,因此可以避免CSRF攻击
它的问题:
- JWT体积太大,jwt包含了header、payload和Signature,包含了很多额外的信息,还需要进行base64url编码,这会使得JWT体积较大,增加了网络传输的开销。
好的,让我们导入jsonwebtoken来生成这个令牌,执行命令
pnpm add jsonwebtoken
pnpm add @types/jsonwebtoken -D
让我们改动一下代码
// app/login/action.ts
"use server";
import z from "zod";
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
const prisma = new PrismaClient();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function loginAction(formData: FormData) {
const username = formData.get("username");
const password = formData.get("password");
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username: parsed.data.username },
});
if (!user) {
return { error: "User not found" };
}
if (user.password !== parsed.data.password) {
return { error: "Invalid password" };
}
// bababab是密钥,这里明文写在这里其实不好,所以建议使用环境变量来,或者去读取密钥文件
const token = jwt.sign({ id: user.id, password: user.password }, "bababab", {
expiresIn: "1d",
});
const cookieStore = await cookies();
cookieStore.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
expires: new Date(Date.now() + 1000 * 60 * 60),
});
console.log(cookieStore.get("token"));
return { success: true };
}
OK,我们获取到了token,然后将token存储在cookie中,这样每一次请求就能将授权信息带给后端了
后端不应该去cookie中取授权信息,应该考虑去http请求头的Authorization中获取授权信息,因为从cookie中拿,会导致出现CSRF的漏洞
可以看到的是,这里并不建议直接将密钥写在这里,你可以有很多方案,比如单独保存一份密钥文件,常见的,你可以将密钥加载环境变量中,在.env文件中添加密钥的环境变量
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: <https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema>
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: <https://pris.ly/d/connection-strings>
DATABASE_URL="postgresql://postgres:12345678@localhost:5432/auth-example?schema=public"
JWT_SECRET=12345678
然后让我们稍微改动一下 action
"use server";
import z from "zod";
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
const prisma = new PrismaClient();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function loginAction(formData: FormData) {
const username = formData.get("username");
const password = formData.get("password");
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username: parsed.data.username },
});
if (!user) {
return { error: "User not found" };
}
if (user.password !== parsed.data.password) {
return { error: "Invalid password" };
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, {
expiresIn: "1d",
});
const cookieStore = await cookies();
cookieStore.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
expires: new Date(Date.now() + 1000 * 60 * 60),
});
console.log(cookieStore.get("token"));
return { success: true };
}
试试看,你可以看到终端打印cookie中的token,表示cookie已经写入到页面上了
OK,就目前而言,你似乎看不到这个功能对我们的系统有什么作用,不过没关系,很快我们就知道它的作用什么了
hello world 玄星啊
比较常见的情况下,我们会遇到这样的需求,比如用户需要登陆才能访问首页,一些常见的后台管理系统就是这样的,如果用户没有登陆,那么就给用户重定向到登陆页,登陆成功后,就跳转到首页
让我们简单来完成这个需求,我们将next首页的信息删除,简单写一些内容,比如输出一句hello world XXX。
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export default async function Home() {
const prisma = new PrismaClient();
const token = (await cookies()).get("token")?.value;
const payload = token ? jwt.verify(token, process.env.JWT_SECRET as string) : null;
if(!payload) {
redirect('/login')
}
const user = await prisma.user.findUnique({
where: { id: payload.id as string },
});
return (
<div>
<h1>Hello World {user?.username}</h1>
</div>
);
}
并且,我们修改一下loginAction,让用户登陆成功后就重定向到首页
"use server";
import z from "zod";
import { PrismaClient } from "@prisma/client";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const prisma = new PrismaClient();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function loginAction(formData: FormData) {
const username = formData.get("username");
const password = formData.get("password");
const parsed = loginSchema.safeParse({ username, password });
if (!parsed.success) {
return { error: "Invalid username or password" };
}
const user = await prisma.user.findUnique({
where: { username: parsed.data.username },
});
if (!user) {
return { error: "User not found" };
}
if (user.password !== parsed.data.password) {
return { error: "Invalid password" };
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, {
expiresIn: "1d",
});
const cookieStore = await cookies();
cookieStore.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
expires: new Date(Date.now() + 1000 * 60 * 60),
});
redirect("/");
}
OK,你可以删除cookie中的token测试一下,你会发现页面刷新后导航到了登陆页,在登录页成功登陆后,会自动导航到首页,展示hello world 玄星啊
优化目录结构,抽象方法getUser
很棒,不过这还没完,让我们简单封装一下这个用户登陆的检查,我打算叫它getUser,OK,让我创建一个专门来存在auth相关的逻辑的文件在src下
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
import { redirect } from "next/navigation";
import { PrismaClient } from "@prisma/client";
export async function getUser() {
const prisma = new PrismaClient();
const token = (await cookies()).get("token")?.value;
const payload = token ? jwt.verify(token, process.env.JWT_SECRET as string) : null;
if(!payload) {
return null;
}
const user = await prisma.user.findUnique({
where: { id: payload.id as string },
});
}
emmmm,好像我们一直在到处写prisma对象的实例化,这种不太方便对它的管理,我打算创建一个db文件夹来管理它
// db/prisma.ts
import { PrismaClient } from "@prisma/client/extension";
export const prisma = new PrismaClient();
OK,让我们替换之前文件中使用prisma的地方,这个太简单了,我就不放代码例子了
封装好getUser之后,我们回到首页,用我们getUser替换之前的逻辑
// app/page.tsx
import { getUser } from "@/auth/get-user";
import { redirect } from "next/navigation";
export default async function Home() {
const user = await getUser();
if(!user) {
redirect('/login')
}
return (
<div>
<h1>Hello World {user?.username}</h1>
</div>
);
}
哇哦,它看起来真简单
当然,走到这里,我们的旅程远远还没有结束,不过这样的程序已经可以使用了不是吗?起来再给自己倒杯水吧。
如果你想获取案例代码,请点击这个链接github.com/tenghuan123…
下集预告:完善JWT,accessToken与refreshToken