next中的鉴权系统

672 阅读13分钟

开始之前

在开始这趟旅程之前,我建议你对JavaScript和TypeScript有基本的了解,并且熟悉React和Next. js框架,由于鉴权系统的内容很多,知识点也多,本节只讲到最基础的登陆注册功能的,后续的未来再展开

设置项目

首先,我们需要使用next的提供的脚手架生成一个简单模版

npx create-next-app@latest

image.png

next的脚手架会让你选一大堆东西,基本上所有东西都按照默认选择就行,除了App Router在我看来是必须的,其他的因人而异。

如果一切正常的话,脚手架已经创建好了一个最基础的项目结构,就像这样:

image 1.png

OK,让我们开始给它装一下包,用啥都行,npm、yarn、pnpm,我这里用的是pnpm,因为这些都不重要,我们快速跳过,执行命令:

pnpm i
pnpm dev

让我们访问localhost:3000,这样,我们就获得了一个基础next项目

image 2.png

真不错!

了解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官方文档

就像这样:

image 3.png

然后,让我们搞一个简单的 填写用户 + 密码的表单,让我们在这个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,我们实现了一个非常基础的表单,没关系,我们后面再慢慢完善它

image 4.png

额,确实太难看了,我们简单润色一下

// 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>
  );
}

image 5.png

看起来不错,让我们继续

login ServerAction的思考以及实现

让我们梳理一下,当我们输入内容,点击提交时会发生什么

image 6.png

好的,让我们简单的实现一下

验证部分,我们可以借助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”

image 7.png

当什么都不填写时,控制台会打印"Invalid username or password”

image 8.png

很棒,但是我们也注意到了我们的问题,我们目前还没有一个真正的用户

创建我们的第一个用户吧,register

好的,让我们继续在项目中创建一个新的路由,创建一个register文件夹,并且在内部创建一个page.tsx、和action.ts文件,就像这样

image 9.png 让我在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的思考以及实现

那么让我们来思考一下,注册的逻辑是什么样子的呢?

image.png 我们简单完善一下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 };
}

试试看,我已经注册了一个账号在我数据库里了,它看起来很不错

image 10.png

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来存储用户的登陆状态

看起来玄星啊已经成功通过了我程序的校验了,好吧,我现在承认,你就是玄星啊,现在我要给你颁发一个令牌了,表明在某一段时间内,你可以在我的程序中畅通无阻的使用各种功能

关于这个令牌的实现,其实有很多方案,常见的方案有:

  1. session
  2. JWT

那么什么是JWT方案呢?

简单来说,就是你在http请求头中,添加鉴权的信息,这个信息是一长串加密后的用户信息,后端每一次处理你的请求时,都会从请求头中获取到这个信息,用特定的解密方案来解开加密,获取里面的数据,然后与实际数据库的数据做对比,如果对比成功,则有权限进行访问,如果不成功则拒绝访问。

它的好处:

  1. 无状态,自身包含了身份验证所需的所有信息,因此,服务器上不会需要存储session信息。这增加了系统的可用性和伸缩性,大大减轻了服务端的压力
  2. 有效避免了CSRF攻击,使用JWT进行身份验证不需要依赖Cookie,因此可以避免CSRF攻击

它的问题:

  1. 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已经写入到页面上了

image 11.png

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 玄星啊

image 12.png

优化目录结构,抽象方法getUser

很棒,不过这还没完,让我们简单封装一下这个用户登陆的检查,我打算叫它getUser,OK,让我创建一个专门来存在auth相关的逻辑的文件在src下

image 13.png

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文件夹来管理它

image 14.png

// 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