Next.js 极简实现 Authentication

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

Authentication 是大部分 Next.js 项目都会遇到的场景,但 Authentication 所涉及的概念众多、技术方案众多,细节众多,导致不少同学对 Authentication 感到不明觉厉,甚至有些"犯怵"。

本篇我们将通过一个极简的 Authentication 实现帮助大家了解其基本概念和实现原理。当然实际项目项目开发中,并不需要从零实现,本篇的最后还会给出大家在实际开发中的推荐技术选型。

  1. 本篇已收录到掘金专栏《Next.js 开发指北》

  2. 系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

概念基础

Authentication 涉及的概念众多,我们列出一些最基本的,防止大家产生混淆。

1. Authentication 与 Authorization

身份验证(英语:Authentication)又称"认证"、"鉴权",指通过一定的手段,完成对用户身份的确认。

身份验证的目的是确认当前所声称为某种身份的用户,确实是所声称的用户。在日常生活中,身份验证其实并不罕见,比如做高铁前查身份证就是一种身份认证。

授权(英语:Authorization)是指根据用户提供的身份凭证,生成权限实体,并为之授予相应的权限。

简单的来说,Authentication 是为了解决"你是谁"的问题,Authorization 是为了解决"你能干什么"的问题。

2. OAuth (Open Authorization)

开放授权(OAuth)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

结合 Authorization 和 Authentication,我们举个例子:

假设你开发了一个图片网站,自己开发了登陆注册功能,用户登录后展示该用户的私有图片,这是 Authentication。你使用第三方网站认证用户身份,比如谷歌登录,让第三方网站提供用户身份认证,这是"认证"服务,也是 Authentication。

而 OAuth 是第三方网站允许你直接操作它的用户数据,比如你接入谷歌相册,你不会知道用户的谷歌账号的密码,但谷歌会给你一个 token,这个 token 决定了你能拥有的权限,比如可以读取谷歌相册里的图片,同步到自己的网站中,这属于"授权"服务(Authorization)。

Cookie,做想必前端的大家很清楚了,它是浏览网站时由网络服务器创建并由网页浏览器存放在用户计算机或其他设备的小文本文件。

Cookie 使 Web 服务器能在用户的设备存储状态信息(如添加到在线商店购物车中的商品)或跟踪用户的浏览活动(如点击特定按钮、登录或记录历史)

Cookie 将数据保存在客户端,而 Session 则将数据保存在服务器端。Session 需要借助 Cookie 来实现,只是 Session 会在 Cookie 中存一个 SessionId,当发起请求的时候会携带该 SessionID,后端根据 SessionID 获取对应存储的用户数据,这样后端就知道了请求者的身份。

4. JWT (JSON Web Token)

JWT 则是与 Session 截然不同的认证解决方案。Session 是将用户数据全部存在服务端,JWT 则是将所有数据都保存在客户端,只是为了防止篡改,会加上签名,每次请求的时候都会携带这些数据,后端解析后就获取了该用户的身份信息。使用 JWT 的好处在于服务器不需要保存任何 session 数据,更容易实现扩展。

实际 JWT 是一段由 .连接的字符串,大致长这样:

上图中的 3 种颜色代表了 JWT 的 3 个部分,分别是:

  1. Header
  2. Payload
  3. Signature

其实 Header 声明了 JWT 元数据,比如生成签名的算法,Payload 是实际需要传递的数据,Signature 是根据 Header 和 Payload 以及 Secret 生成的签名。

我们可以在一些 JWT 加密解密网站更直观的看出其组成:

具体 JSON 对象怎么转成字符串的呢?其实就是 Base64URL 算法。

注意:JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

项目实现

1. 项目初始化

运行:

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

效果如下:

创建项目后,进入项目目录,运行 npm run dev开启开发者模式。

2. 登录注销

修改 app/page.js,代码如下:

jsx 复制代码
import { redirect } from "next/navigation";
import { getSession, login, logout } from "../lib";

export default async function Page() {
  const session = await getSession();
  return (
    <section className="p-2">
      <form action={async (formData) => {
      "use server";
      await login(formData);
      redirect("/");
    }} className="mb-2">
        <label htmlFor="email" className="block text-sm font-medium leading-6 text-gray-900">
          Email:
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 mb-2"
          />
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
          Login
        </button>
      </form>
      <form
        action={async () => {
          "use server";
          await logout();
          redirect("/");
        }}
        className="mb-2"
        >
        <button
          type="submit"
          className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
          >
          Logout
        </button>
      </form>
      <pre>{JSON.stringify(session, null, 2)}</pre>
    </section>
  );
}

此时前台界面效果如下:

为了简单起见,我们用一个输入框表示用户登录的信息。当用户输入信息后,点击 Login 按钮,页面底部会显示用户信息。

安装依赖项 jose

bash 复制代码
npm i jose

jose 是一个用于 JSON 对象签名和加密的 JavaScript 模块,支持 JWA, JWS, JWE, JWT, JWK, JWKS,而且支持多个运行环境,如 Node.js、Browser、Cloudflare Workers、Deno、Bun 等。

我们使用 jose 是为了实现 JWT 的加密和解密,jose 文档提供了 SignJWTjwtVerify 这两个 API。

SignJWT 用于加密,基本用法如下:

javascript 复制代码
const secret = new TextEncoder().encode(
  'cc7e0d44fd473002f1c42167459001140ec6389b7353f8088f4d9a95f2f596f2',
)
const alg = 'HS256'

const jwt = await new jose.SignJWT({ 'urn:example:claim': true })
  .setProtectedHeader({ alg })
  .setIssuedAt()
  .setExpirationTime('2h')
  .sign(secret)

console.log(jwt)

上节我们讲过,JWT 由 Header、Payload、Signature 三部分组成。new SignJWT 的时候传入的是 Payload,setProtectedHeader 设置的是 Header,在上面这段代码中,我们指定了签名算法为 HS256。setIssuedAtsetExpirationTime 是为了设置 iat 字段(签发时间)和 exp 字段(过期时间),这都是 JWT 规定的 7 个官方字段之一。最后的 sign 是为了生成签名,生成签名需要一个 secret,这个 secret 不能对外暴露。

熟悉了基本用法,我们就可以写一个 encrypt 函数,用于 JWT 加密:

javascript 复制代码
export async function encrypt(payload) {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("10 sec from now")
    .sign(key);
}

jwtVerify 用于解密,基本用法如下:

javascript 复制代码
const secret = new TextEncoder().encode(
  'cc7e0d44fd473002f1c42167459001140ec6389b7353f8088f4d9a95f2f596f2',
)
const jwt =
  'eyJhbGciOiJIUzI1NiJ9.eyJ1cm46ZXhhbXBsZTpjbGFpbSI6dHJ1ZSwiaWF0IjoxNjY5MDU2MjMxLCJpc3MiOiJ1cm46ZXhhbXBsZTppc3N1ZXIiLCJhdWQiOiJ1cm46ZXhhbXBsZTphdWRpZW5jZSJ9.C4iSlLfAUMBq--wnC6VqD9gEOhwpRZpoRarE0m7KEnI'

const { payload, protectedHeader } = await jose.jwtVerify(jwt, secret, {
  issuer: 'urn:example:issuer',
  audience: 'urn:example:audience',
})

console.log(protectedHeader)
console.log(payload)

jwtVerify 的第一个参数是 jwt 字符串,第二个参数是 secret,用于签名验证,第三个参数是 options。

熟悉了基本用法,我们就可以写一个 decrypt 函数,用于 JWT 解密:

javascript 复制代码
export async function decrypt(input) {
  const { payload } = await jwtVerify(input, key, {
    algorithms: ["HS256"],
  });
  return payload;
}

根目录新建 lib.js,完整代码如下:

javascript 复制代码
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

const secretKey = "secret";
const key = new TextEncoder().encode(secretKey);

export async function encrypt(payload) {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("10 sec from now")
    .sign(key);
}

export async function decrypt(input) {
  const { payload } = await jwtVerify(input, key, {
    algorithms: ["HS256"],
  });
  return payload;
}

export async function login(formData) {
  // 获取用户信息,并进行验证
  const user = { email: formData.get("email") };

  // 创建 Session
  const expires = +new Date(Date.now() + 10 * 1000);
  const session = await encrypt({ user });

  // 存储 Cookie
  cookies().set("session", session, { expires, httpOnly: true });
}

export async function logout() {
  // 删除 Cookie
  cookies().set("session", "", { expires: new Date(0) });
}

export async function getSession() {
  const session = cookies().get("session")?.value;
  if (!session) return null;
  return await decrypt(session);
}

在这段代码中,login 的代码并不复杂,我们省略了信息验证的步骤(正常要查询数据库进行比对)。验证通过后,我们将用户信息生成 jwt 字符串。最后创建了一个名为 session 的 cookie,其值为 jwt。后续页面请求都需要携带该 cookie 字段。

当需要获取用户信息时,调用 getSession 函数,其实现也很简单,就是解密 jwt 获取其中的用户信息。

当需要注销时,只需要设置 cookie 中的 session 字段过期就行。当然在这里,我们设置了 session 字段的有效期为 10s,以此来模拟长时间未操作而导致的登录状态失效。

如此我们就实现了一个简单的登录注销功能,浏览器效果如下:

注:你可能会对这些字段感到好奇,其中 user 字段是自己构建的,iatexp 字段是调用 setIssuedAtsetExpirationTime 生成的。

3. 更新 Session

现在我们实现的是 cookie 有效期为 10s,10s 后刷新页面,cookie 过期,不能再获取用户信息。但在实际项目中,当用户刷新页面的时候,其实会更新 cookie,延长 cookie 的过期时间。这个功能我们又该如何实现呢?

其实也很简单,修改 lib.js,添加代码如下:

javascript 复制代码
export async function updateSession(request) {
  const session = request.cookies.get("session")?.value;
  if (!session) return;

  // 更新 Session
  const parsed = await decrypt(session);
  const expires = new Date(Date.now() + 10 * 1000);
  const res = NextResponse.next();
  res.cookies.set({
    name: "session",
    value: await encrypt(parsed),
    httpOnly: true,
    expires
  });
  return res;
}

新建 middleware.js,代码如下:

javascript 复制代码
import { updateSession } from "./lib";

export async function middleware(request) {
  return await updateSession(request);
}

此时浏览器效果如下:

如果 Cookie 没有过期,每次刷新都会延长 Cookie 的时间。

以上就是一个最小的身份验证实现,页面读取用户信息,中间件刷新用户信息。当然实际的处理会更为复杂,但这却是一个很好的理解 Authentication 的例子。

推荐技术选型

实际项目开发中,推荐 3 个主流的技术选型:

1. Clerk

Clerk 提供了一个开发人员友好的身份验证和用户管理解决方案,帮助开发者轻松构建和管理用户身份验证、用户账户和权限管理功能。它提供了安全的身份验证、社交登录集成、角色和权限管理等功能。

2. Supabase

简单来说,Supabase 是 Firebase 的开源替代品,属于 BaaS(后端即服务)产品。所谓 BaaS,开发者只需要开发和维护前端代码,由 BaaS 服务商提供了开发应用所需要的后端服务,如用户身份验证、数据库管理、推送通知(针对移动应用程序),以及云存储和托管等。

如果要比较 Clerk 和 Supabse 的话,Clerk 更专注于身份验证和用户管理,对应功能更加丰富。Supabase 实现的功能更多,身份验证只是其中之一。

3. Next-Auth

Next-auth 不是平台,是一个开源库,可以帮助我们快速实现登录注册等功能。

总的来说,如果要快速接入登录注册功能,最好还是使用平台,也就是 Clerk 和 Supabse,其中 Clerk 提供的功能更为丰富,但作为平台,Clerk 和 Supabse 都有免费版限制。

参考链接

  1. www.ruanyifeng.com/blog/2018/0...
  2. nextjs.org/docs/app/bu...
  3. www.youtube.com/watch?v=DJv...
相关推荐
熊的猫32 分钟前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
别拿曾经看以后~2 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
川石课堂软件测试2 小时前
性能测试|docker容器下搭建JMeter+Grafana+Influxdb监控可视化平台
运维·javascript·深度学习·jmeter·docker·容器·grafana
JerryXZR3 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
problc3 小时前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter
Gavin_9153 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
懒大王爱吃狼4 小时前
Python教程:python枚举类定义和使用
开发语言·前端·javascript·python·python基础·python编程·python书籍
小牛itbull5 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
待磨的钝刨5 小时前
【格式化查看JSON文件】coco的json文件内容都在一行如何按照json格式查看
开发语言·javascript·json
前端青山10 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js