Deno - JWT Access and Refresh Tokens Authentication

367 阅读9分钟

在这篇文章中,我会尝试在 Deno 中使用 JWT 来实现无状态的身份验证。因为 JSON Web Tokens 只能在过期之前失效,所以我们会引入一个持久化存储(Redis)作为额外的安全层。也就是说,我们会将access tokenRefresh Token存储到 Redis 中,以便随时撤销令牌。

文章来源:codevoweb.com/deno-jwt-ac…

你将从这篇文章中学到:

  • 使用RSASSA-PKCS1-v1_5加密/解密签名算法生成私有和公用CryptoKey对
  • 使用pkcs8spki密钥格式导出生成的私钥和公钥,然后将它们存储到环境变量中。
  • 从环境变量中导入PEM私钥和公钥然后将它们转换为CryptoKey对象
  • 使用RS256算法签署并验证JSON Web Token
  • 在 Deno 中实现JSON Web Token验证流程的 Restful API。

前提:

  • 使用docker-compose.yml在系统中运行 Redis 和 MongoDB。
  • 需要安装最新版的deno,可以运行deno upgrade来升级到最新版的 deno。
  • 需要了解在 Deno 中设计 API 的基础知识。

在本地运行 Deno JWT Authentication API

  1. 从 GitHub 拉取项目(链接)。
  2. 在项目根目录执行命令(docker-compose up -d)启动 MongoDB 和 Redis 服务。
  3. 在终端执行命令denon run -A src/server.ts来启动 Deno HTTP 服务端。
  4. 发起 HTTP 请求来测试 JSON Web Token authentication 流程。

JWT 认证流程

一个JSON Web Token会包含三个部分的内容,这些内容由.隔开。这三部分的内容分别是:

  • Header:通常由两部分组成,包括令牌类型(typ)以及使用的密码算法(alg)。例如HMAC SHA256RSA
  • Payload:包含可验证的 claims,例如用户的身份和允许执行的操作。
  • Signature:使用 Header 中声明的算法对 Base64 编码的 Header,Payload 和私钥进行签名的结果。

截屏2023-01-15 12.37.57.png

  1. 用户会使用邮箱和密码登录。服务端会请求服务器查看用户是否存在并且根据在数据库中的 Hashed 密码来验证密码。
  2. 服务端使用用户的信息和私钥生成 JWT access 和 refresh token。一旦 JWT token 生成之后,服务端会在 Redis 中存储 token 信息。然后将 access token 和 refresh token 作为 HTTP Only cookie 返回给客户端或前端应用程序。
  3. 对于后续设计用户验证的流程,用户在 Header 中或者 Request Cookies 对象中携带 access token。
  4. 当服务端收到 access token,就会使用公钥验证签名部分并从 payload 中抽取用户信息。如果 access token 是有效且未过期,服务端将会使用用户信息查询 Redis 以检查该 session 是否有效。
  5. 如果 Redis 数据库中查询到一个有效的 session,服务端会查询 MongoDB 以验证该 token 所属的用户是否仍然存在,再执行后续的处理。

无状态 JWT 身份验证的缺陷

JWT 最大的问题就是它不能轻易撤销或使其失效,因为 JWT 是独立的。

JWT 失效/撤销

JWT 签发之后不能撤销。这意味着 JWT 直到过期才会失效。我们需要在退出登录之后使得 JWT 立即失效。

拒绝资源访问

某种情况下,无法阻止某个人访问某个资源,因为它们的 token 在过期之前还能访问该资源。

陈旧数据

假设将某个用户的权限降级,这不会立即生效,直到令牌过期。除非系统有做其他特殊处理。

JWT 可以被劫持

JWT 通常不加密,因此黑客可以劫持令牌并执行攻击,直到它过期为止。 这变得更容易,因为中间人 (MITM) 攻击只需要在客户端和服务器之间的连接上进行。

JWT 漏洞解决方案

在这里需要澄清一下,并没有任何系统能够保证 100%的安全,黑客总是能够在代码或者第三方库中发现并使用这些漏洞来滥用服务。但是我们可以实施一些推荐的安全措施,让他们难以完成这些侵入的过程。

在数据库中保存撤销的 JWT 一种流行的结局方案是在数据库中存储已经撤销令牌的列表。这样,如果请求的令牌是已经撤销的一部分,就可以阻止用户。这种方法反转了无状态 JWT 的最终目的,因为必须在使用用户的请求之前做一个额外的调用检查用令牌是否存在。

在持久层中存储 JWT metadata 还有一种方案是在 Redis 作为 session 存储,这样,我们可以存储 JWT metadata 并在设定的时间之后使它们过期。对于每个需要身份验证的请求,我们将查询 Redis 数据库以检查用户是否有有效的 session,然后再调用下一个操作。

创建 Deno 项目

首先创建一个文件夹,在这篇文章中,将使用 VS Code 作为 IDE。在此之前可以在下载Deno的 Language Server 插件。

mkdir deno-refresh-jwt

之后在根目录下创建.vscode文件夹,在该文件夹下新建一个settings.json文件并添加如下配置以支持 deno。

.vscode/settings.json

{
  "deno.enable": true,
  "deno.unstable": true
}

这个设置会在 VS Code 中准备 Deno 的开发环境。之后我们需要下载整个开发过程中需要第三方依赖。首先在根目录文件夹下面创建一个src文件夹,在src文件夹下,创建一个deps.ts的文件,然后加入以下依赖。

src/deps

export {
  Application,
  helpers,
  Router,
} from "https://deno.land/x/oak@v11.1.0/mod.ts";
export type {
  Context,
  RouterContext,
} from "https://deno.land/x/oak@v11.1.0/mod.ts";
export * as logger from "https://deno.land/x/oak_logger@1.0.0/mod.ts";
export { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
export {
  create,
  getNumericDate,
  verify,
} from "https://deno.land/x/djwt@v2.7/mod.ts";
export type { Header, Payload } from "https://deno.land/x/djwt@v2.8/mod.ts";
export { config as dotenvConfig } from "https://deno.land/x/dotenv@v3.2.0/mod.ts";
export { connect as connectRedis } from "https://deno.land/x/redis@v0.27.3/mod.ts";
export {
  Bson,
  Database,
  MongoClient,
  ObjectId,
} from "https://deno.land/x/mongo@v0.31.1/mod.ts";
  • oak:Deno 中处理 HTTP 请求的框架
  • oak_logger:oak 框架的日志中间件框架
  • cors:在 Deno 中开启 CORS
  • djwt:在 Deno 中签发和验证 JSON Web Tokens
  • redis:Redis client
  • mongo:MongoDB client

接下来开始使用oak框架写一个简单的 HTTP Server。首先在src目录下创建一个server.ts文件,然后插入以下代码片段。

src/server.ts

import { Application, Router } from "./deps.ts";
import type { RouterContext } from "./deps.ts";

const app = new Application();
const router = new Router();

// Health checker
router.get<string>("/api/healthchecker", (ctx: RouterContext<string>) => {
  ctx.response.status = 200;
  ctx.response.body = {
    status: "success",
    message: "JWT Refresh Access Tokens in Deno with RS256 Algorithm",
  };
});

app.use(router.routes());
app.use(router.allowedMethods());

app.addEventListener("listen", ({ port, secure }) => {
  console.info(
    `🚀 Server started on ${secure ? "https://" : "http://"}localhost:${port}`
  );
});

const port = 8000;
app.listen({ port });

然后打开终端执行以下命令来启动 HTTP 服务器。以下命令会在8000端口上启动 Oak Server。

denon run --allow-net --allow-read --allow-write --allow-env src/server.ts

之后在浏览器中访问此 API:http://localhost:8000/api/healthchecker,就会得到如下相应:

截屏2023-01-15 12.37.04.png

在 Docker 中启动 Redis 和 MongoDB

在根目录下创建文件docker-compose.yml

docker-compose.yml

version: "3.9"
services:
  mongo:
    image: mongo:latest
    container_name: mongo
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
      MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
    env_file:
      - ./.env
    volumes:
      - mongo:/data/db
    ports:
      - "6000:27017"

  redis:
    image: redis:alpine
    container_name: redis
    ports:
      - "6379:6379"
    volumes:
      - redisDB:/data
volumes:
  mongo:
  redisDB:

上面的代码将从 Docker Hub 拉取最新的 MongoDB image,使用在.env文件中提供的环境变量启动 MongoDB 服务,并将默认的 MongoDB 服务端口映射到本机端口 6000。

然后,再将从 Docker Hub 中拉取 Alpine Redis image,构建 Redis 服务,并将端口 6379 映射到默认的 Redis 端口。

因为我们在docker-compose.yml中使用占位符作为 MongoDB 服务的配置,之后需要创建一个.env文件来保存系统的环境变量。因此,在根目录中创建一个.env文件,并添加以下环境变量。

.env

MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=deno_mongodb

NODE_ENV=development
SERVER_PORT=8000

MONGODB_URI=mongodb://admin:password123@localhost:6000

打开终端并执行以下命令启动 MongoDB 和 Redis 容器

docker-compose up -d

命令执行完毕后,打开 Docker 桌面应用程序以查看容器是否正在运行。

截屏2023-01-15 12.39.56.png

连接到 Redis 与 MongoDB 服务

接下来创建一些工具方法来连接到 Redis 与 MongoDB 服务

连接到 Redis 服务

首先,在src文件夹下创建一个utils文件夹,在该文件夹下创建一个connectRedis.ts文件,并插入以下代码片段:

src/utils/connectRedis.ts

import { connectRedis } from "../deps.ts";

const redisClient = await connectRedis({
  hostname: "localhost",
  port: 6379,
});

console.log("🚀 Redis connected successfully");

export default redisClient;

连接 MongoDB

然后再创建connectDB.ts文件,添加以下代码:

src/utils/connectDB.ts

import { MongoClient, dotenvConfig } from "../deps.ts";
dotenvConfig({ export: true, path: ".env" });

const dbUri = Deno.env.get("MONGODB_URI") as unknown as string;
const dbName = Deno.env.get("MONGO_INITDB_DATABASE") as unknown as string;

const client: MongoClient = new MongoClient();
await client.connect(dbUri);
console.log("🚀 Connected to MongoDB Successfully");

export const db = client.database(dbName);

以上代码从环境变量中导入 MongoDB 的连接配置,然后创建一个新的 MongoDB client,然后调用client.connect(...)方法创建 MongoDB 连接池

最后,调用client.database(...)方法创建一个 database

创建 Database Model

之后我们需要创建一个 Model 对象,以方便我们查询和更新数据库。我们通过db.collection()方法创建一个 Collection 对象,我们可以使用这个来实现 MongoDB 的操作。之后又在 email 字段上创建了唯一索引,确保没有重复使用的邮箱地址。

生成私钥和公钥

在这一小节中,我们将使用 Web Cryptography API 创建 PEM 证书,从 PEM 证书生成私钥和公钥,再使用安全密钥格式导出它们,并将 CryptoKey Pairs 存储在 .env 文件中。 首先,打开.env文件然后添加以下环境变量:

.env

ACCESS_TOKEN_PRIVATE_KEY=
ACCESS_TOKEN_PUBLIC_KEY=

REFRESH_TOKEN_PRIVATE_KEY=
REFRESH_TOKEN_PUBLIC_KEY=

生成 Crypto Keys

utils文件夹下生成generateCryptoKeys.ts文件

src/utils/generateCryptoKeys.ts

function arrayBufferToBase64(arrayBuffer: ArrayBuffer): string {
  const byteArray = new Uint8Array(arrayBuffer);
  let byteString = "";
  byteArray.forEach((byte) => {
    byteString += String.fromCharCode(byte);
  });
  return btoa(byteString);
}

function breakPemIntoMultipleLines(pem: string): string {
  const charsPerLine = 64;
  let pemContents = "";
  while (pem.length > 0) {
    pemContents += `${pem.substring(0, charsPerLine)}\n`;
    pem = pem.substring(64);
  }
  return pemContents;
}

const generatedKeyPair: CryptoKeyPair = await crypto.subtle.generateKey(
  {
    name: "RSASSA-PKCS1-v1_5",
    modulusLength: 4096,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: "SHA-256",
  },
  true,
  ["sign", "verify"]
);

function toPem(key: ArrayBuffer, type: "private" | "public"): string {
  const pemContents = breakPemIntoMultipleLines(arrayBufferToBase64(key));
  return `-----BEGIN ${type.toUpperCase()} KEY-----\n${pemContents}-----END ${type.toUpperCase()} KEY-----`;
}

// use the new toPem function to create PEM format strings for the privateKey and publicKey
const privateKeyBuffer: ArrayBuffer = await crypto.subtle.exportKey(
  "pkcs8",
  generatedKeyPair.privateKey
);

const exportedPublicKey: ArrayBuffer = await crypto.subtle.exportKey(
  "spki",
  generatedKeyPair.publicKey
);
const privateKeyPem = toPem(privateKeyBuffer, "private");
const publicKeyPem = toPem(exportedPublicKey, "public");
console.log("\n");
console.log(btoa(privateKeyPem), "\n\n");
console.log(btoa(publicKeyPem));
  • arrayBufferToBase64()主要将 array buffer 类型的数据转换为 base64 字符串形式
  • breakPemIntoMultipleLines()将 base64 字符串拆成每行 64 个字符
  • 调用crypto.subtle.generateKey()方法生成 CryptoKey pairs。具体参数可以参考MDN
  • 之后我们将 CryptoKey pairs 转换成 PEM 证书然后使用 pkcs8 和 spki 分别导出私钥和公钥。导出的 PEM 证书可以保存到本地的文件系统或者环境变量文件中。
  • 最后,我们将私钥和公钥转成 base64 形式并打印在控制台。

我们将私钥和公钥转换成 base64 编码的字符串,以避免在 docker compose 在从.env文件读取环境变量的时候收到不必要的警告。

现在打开终端,然后执行一下命令,生成私钥和公钥。

deno run src/utils/generateCryptoKeys.ts

执行以上命令之后会生成私钥和密钥,并将生成的第一段 base64 代码作为ACCESS_TOKEN_PRIVATE_KEY,第二段稍短的 base64 代码作为ACCESS_TOKEN_PUBLIC_KET。 重复执行以上命令,生成的第一段 base64 代码作为REFRESH_TOKEN_PRIVATE_KEY,第二段稍短的 base64 代码作为REFRESH_TOKEN_PUBLIC_KEY。 最后,.env文件将会如以下所示:

.env

MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=deno_mongodb

NODE_ENV=development
SERVER_PORT=8000

MONGODB_URI=mongodb://admin:password123@localhost:6000

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRZ0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1N3d2dna29BZ0VBQW9JQ0FRRFRvdDNVWjhYWjcyNW0KS3VsWHNUckhNVndvREYzQ21ibWpxZG5RNmxvMHA2WjdWd1VBc0NKS1VQUXMvWGZjVjVobHRGeEVhL3NLZWkxcQpTRWJqTFFWaFpGQTFINkpObTltWHZLQTFUQXpCeWNDajFKbUd3d1hobGxxRGhNWkdsUk90SnlVVEk1akZSOThQCkhqcWVQeDg1S1JlMytmVUplMEZKVHpjNWNYYjRDY1RmRkJlZWpsb3VwZ2pRbkVuRUdYRCtaeTNxemxxY0gwcHgKWWhoTFNvb0MxN0lUcWhvY3BKSEVvMkhZRzBHK2UyU1VEZnViL3JmK1ZOUXlpOFVKRDBsMUszSGQyUm4zbmJJRAo0cGpHTkdtMkdySkFLeVpOOFh3VjByNTRWVituRkd3ZjhCN21kckZzN1RCakZPYnI3MVVTRGtERE8yU0JHQ1FiCkZOeU9RU1V5ejUxK0h6QWZRUjZWNUplcmFJZnBXa2x3a3ptNW90K1VEeXJUcy9uYVpFNTFTazdVVUg1dWI1SFAKY0ZFT3ROUTBCcXpOdithY0lDbzcrcGJqSzRWcXBzZGxhMy85aWI2QkpJajhweDIza2FrY1dMU0JEL1pqQ0E4eAo1Z1dqWjI5dFhrZWdUNzJvaStLQUUvRWM4djZoanM3c2ZoSVpDQTJJMkJaYnVxZ0pNMzVnaHkwVzFkckhGSHN1ClVVWjdTTGg0T1Z6SnI2ZG4yUHdvSjIxY2tSdTF4YkpmbU1DWklhRDFXTGI3aU5zeklsTjQ1Q1Y0Z0NqL25zb1QKL0YrQ1VqTHZISExOMlRCN1VpUDVPQWVSVEFmYndtQ0JTOS9jMmh0UzFjUnI1ZEs1VVU5ZmdXN3lldTgzM0loUQpQSEJHME9jMnlOMmR0dnBEclFEUUlndjdSRHdlSndJREFRQUJBb0lDQUgwNlF0NmJaUFEyKy9GU2RPNWh2WEQrCllSU0ZkTGxnY3Z0SDFzNEt6Y09ZYkNkUmIzRmZ4M3FIK21QZ3U1clM3aWRJR015WHhGMEh2SFhHUE1QUjhQd1cKK21ya3hBbitMVHZlN2tGN05aVTVoMWFweHdwNXZiSWxZSHV3Qmc3ZnlWdk03T0F0VVFsekpLYnljU2NRSEs2YgpCU0Rrczd5ZmhSc1cxNHVTK3gxNzBsVlpzendyNldydTdncGFZRCs3K2lOZTlFbWJQdnhnZTVFcHhVeFAxK2drCnI0ZDVRS2d3TE56WS9GMStpMkZsN0RhN0syVzd6QzJmRGt5MmJhbVZ0UmF5MVZhN1R1VTVGNHU2K2tGVjVETlEKQ3Fkem9OL0FBM2Q2VXlBckRFVjJEU0M4MmR2dTRiK2RmZU16REEwUmVob0wrd0JLMVlhWnZVdVZCWWpiUFdHRwo1MWh6bFpSMlJOejJPU1JTRGdqVXdSWDd1MVY5VldNWG50NEhUY3lGSjlHVWRZMDdtS1EzOXUvN1FQOHpPQVp6Ck9CazdBM1QxU1Z4bHg5d0lZeHRUUFVCaXhSbDR5TXBTcitpYTlkeTMvSHVpVkNwVnRnYXJaNHN5K0xsTnNRWisKc01MVDZTWkoxMnNBNy90YkFRTkZselNCN0lZbTdNME1vZ3pQeWJpN0txeGp2MnVON0RQZE5Kb0F5bzUvRE15SgprNjlXaEdJMElYZDJDQU5XNWlzT2NiYkh4U3UrbDRuTitBbUtxd3hYWmVMQjFFaEh2ZzBURS9QSTJKa2JZN2FnCnc5VTBPQzZBOTNBMTBsR25NQXJySzVwaGlHYm9Kc1hpa01IekQ2cTRuWUN1M3FBQ29CTlZ3TDZ6eXMybEc0WUIKdmVVSXl4RDVydVV1UHBkdnhsdlJBb0lCQVFEdjhLc1Q4eGM5MW5WRmo2UFQzd2o5b1NpTzFheUNWYkNVOFdCZQpQTUxrNlM4eFFIUkc1NUNlV25jRDBEOXJuUzVHbDlnUzRiYVo3ZFZEUjZtbDB5MGZ2ZzRjMzV5ZjZyUmpiUlJVClV5ZHVYVCtzQlZMTkMrQy9tODk1MGRsK1JJSDREMHFHRlhaLzhTNGFCaUlNQ1ZDa0poU0ErT0dDeTgvbE11ajUKTW1IUis4bE13Zmp3NUNYQXJWYnpRMHpUZ1lYd2Z2TXkzSjlOejgyRXdzL0pORDl2U3Rxb2EreVg4cTI2UFlyOAp1VDlyb1hJbFVmYm5XSmJ2ZXlaZVc5ZXdOendvNVNNeHU5YVU4alYrVVlYaEFnWm9PM3Y3NloxRmVoUmxzM25vCkthc2cxZWs2MlVBaXZzaVoxbktQYVFMemtpYVNXVnM5eEJObWI0YTVnS3Z5MHJndkFvSUJBUURoelRjMWh3aXYKNm15T3pkM2dyaW83OTV0WHRvZHkzZXhLV0F0VHRONFNSdWlhL2luelJ1eGpydEhsb0xtYTd2MHNZRVJKTVhHYQpnQjhpaXUvRXZ4R29mVGhIcHU2cGE5aFptK2I3OGY3NHZkMnk4MUV2SjhTM3dEUEhrd2JpMzg1cDk5Q3JvSHZPCkxreWxmM1Btd0UzYkxRUzlUU0Z5M3pBL21PMXV3a21QbXhYVVJrV1JOSHpkVVN1YzlmTTlBZVkvWXFRWXgvUUMKM2hBVXpaOE43NnpCbnZ2VUxQWmxwNHNwa0FvekV3aFR4VENEMnNsM3FMd3NXcDBnV0c0a3A1b3Q2Zy9ETkxKTgozZXlYK3dIb2xlWEJadW5VVVNsV0lQMVB4aUl3ajRDZU9zNGJPd2hFb294d3VnSlpzQVJDZGEvdlROa2FLMGNlCldHcG0zTCtJNFFPSkFvSUJBQmQwQ09Uc1VBdEZXVFV4Y3l3VWt3Wm5xRlU5NFp6anoxemZzekhDOHJINWNSbDUKV1dSTTRqLzRTOFhkcHpWWHFkeFFuMWhKSTlZci96cVNXS3pTMVloU3hZSmhBU2hJZ3RWdEpoMlArenk0ZEs3VgozbUFZbHlGams0WXUwdm1hckxHWW5RbzZNdGtTdEJUcklJellwRDlIVVozQnRobFkzcnRpbkk4dk00eVk5ZlpBCng1cVVVblJnL1N6T0dVWmJWTUpMUm01a1RsWUd4K29BT050TDloOWt5N2JHeGR1Y1p3cmJWU2lhMnU0a1c4bjIKRnhKS0FJYnNITFlBZURiTFQyQVg5YmE0eTZMSGdoOFV6T2RQa1Z6QzQ3MmQramQrVlZ3VGpRajZlYlc5OHd4RApqQmRaV3JaZTFkZmF4ZVVWRmh3Y0MrVWZzMTNCN1FOWTVuWFh6eFVDZ2dFQUs5ME1tNFpXeHEyWVZ3bGd6N09sCm1xNlg2NnNXbHRiTGZ3bXBjYUpSL1dUdTdLVHhDMFE4eVlSOVc4a2tKUmZGOEtmbXUvMHgzMXlDTDlpamlTbkEKeVdWQjJKRnlEVkZZM3RkdFFJWWJETUQ5WHpUckVXajlTdUM0YmsxK2FmWW1CK25QREhnSmROMERvS2Fvb2l1NwpOQmVEc3k1WGtCUVJNRm1KemhsSjV1NnVoK1Q2d0tGY25EV1hiazlNNkE0RlowekhLZFUxN3BTcXRRL1lsUUY1CklzZTZqZFlLSzJjbm5uUlB0dW84bE9GYWNsSy9EbEtsODB2SytDeVZnT05hRFE5SjdwYS9DR2RTL1pjU0lOZDEKb1dOWGl4b1ZHSmtoL0N3MkdnN1dZbVowQVZBdlkvM2JvRTVTQkpBdjA2VSsveEtEbmhUSUpQbngrWGRxY2JHYwpXUUtDQVFFQXBFWTdBM2h5UFl4NmtiWTc1b3o3UmRSdkltaEJ1d1FxU0d1d0NGSU4vaWMyME13S0x1M3ZYM2JzCmNqcTRVTmR0NmhkSUNIbklaRUhuSWhxYW1ZaURXNTNEMWVIbW5KL1E1Y1AvUWtRT3hFWTc4MXljaWNhZ1JSb0YKczMyVHYzZkwydTJIcGtBS1B6NWc2RWszMUtmVncrYyt4YmN1MzNWNlFieU43ZnBObnFSelNMQ1FpQzhoTENTbgo0MS9ES3FObHFaZzVMVzhzSzF2Y1RVcXl0OEl3RlRMVC9abEVYaXRSVkdvUk5LYTJPUVdiYkxtMnFaQlV5UnpzCmxFcVh1a1hSM29LTCs4ek5YWUhnUGk1TlowUy9QZXJzSVovTWVkMUx4N1F3U01aazhsQnJVQWdxdTNnL09wNkkKaThSQ2hSaERWZTZYOEw2RUhTVGdPVGRJdXl6bXB3PT0KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQ==
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUEwNkxkMUdmRjJlOXVaaXJwVjdFNgp4ekZjS0F4ZHdwbTVvNm5aME9wYU5LZW1lMWNGQUxBaVNsRDBMUDEzM0ZlWVpiUmNSR3Y3Q25vdGFraEc0eTBGCllXUlFOUitpVFp2Wmw3eWdOVXdNd2NuQW85U1poc01GNFpaYWc0VEdScFVUclNjbEV5T1l4VWZmRHg0Nm5qOGYKT1NrWHQvbjFDWHRCU1U4M09YRjIrQW5FM3hRWG5vNWFMcVlJMEp4SnhCbHcvbWN0NnM1YW5COUtjV0lZUzBxSwpBdGV5RTZvYUhLU1J4S05oMkJ0QnZudGtsQTM3bS82My9sVFVNb3ZGQ1E5SmRTdHgzZGtaOTUyeUErS1l4alJwCnRocXlRQ3NtVGZGOEZkSytlRlZmcHhSc0gvQWU1bmF4Yk8wd1l4VG02KzlWRWc1QXd6dGtnUmdrR3hUY2prRWwKTXMrZGZoOHdIMEVlbGVTWHEyaUg2VnBKY0pNNXVhTGZsQThxMDdQNTJtUk9kVXBPMUZCK2JtK1J6M0JSRHJUVQpOQWFzemIvbW5DQXFPL3FXNHl1RmFxYkhaV3QvL1ltK2dTU0kvS2NkdDVHcEhGaTBnUS8yWXdnUE1lWUZvMmR2CmJWNUhvRSs5cUl2aWdCUHhIUEwrb1k3TzdINFNHUWdOaU5nV1c3cW9DVE4rWUljdEZ0WGF4eFI3TGxGR2UwaTQKZURsY3lhK25aOWo4S0NkdFhKRWJ0Y1d5WDVqQW1TR2c5VmkyKzRqYk15SlRlT1FsZUlBby81N0tFL3hmZ2xJeQo3eHh5emRrd2UxSWorVGdIa1V3SDI4SmdnVXZmM05vYlV0WEVhK1hTdVZGUFg0RnU4bnJ2Tjl5SVVEeHdSdERuCk5zamRuYmI2UTYwQTBDSUwrMFE4SGljQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==

REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUpRd0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQ1Mwd2dna3BBZ0VBQW9JQ0FRRFpqbkxqYUdCUUN0VmUKbFpIZGF2bHBvWVRaOExJd1VDeG02TWhPQzVJQzdoV3dBVzdSUFdvb2xiZjRzUXZMQUx6ZEVJV05iblloWWlZcApUZzlNRmE3eFdBcW9tMUMrRFRMa29LQ3BqNExmcU1WVnl2ZlMzcGlKZXpUOEl5VmhjMC81dzg3RlF5dHdkS3o1CmpnMEZPbEh4c2lwclU2VTJ0SlBwdTFSUVh3eFRsNVRpWWhEeGhwWUc5NXAyZDl4V2tyUzFnell1ODVLQkMvU2MKRWJicW90ZHhuMUxxSDlCakR5UVM0MVBXNXphUExNdko1d1NIT2xqWUMvdlNnSXN4N0oyTFcrZzFybmtuamw4eQovbUR0RkJsZC9qYnRIZStZVUZLby84cXpkak80ajZHUUh1N2gvVGFXT1dJYjdUWlBsdnB2TS92djljeHVYUmcwCnFRYjA0L2dIbG03dkxIM2ZicnZjUGFrZzBNdm5VVVdoaVRWc3lCdDdMZUxrMzhmeFBxUFQwQUhGT0YvcFdOMVcKaWd3R1d6UDYxUzRvNkwrU0RmZFpBS3c1R3BMRzZ2M3Y2aFhranVBdUY3TjVTMWRYOXNVbml4OTdZNUpLSmptbQpYb2ZYNjYvZ05GdUhtOXZqenVENnB0MHB6R0taVGl0VWIwbVUwT3h2UCtWVmlXY0VLYmo1MEkrNm50OVptWXZqCkd4L1JTTC9lcDNjVDU1d1FYUGJrWWFpeklCU3o0WTM4dmI3SVRQdlBNbTYrSTJDMnl6OG5lODFRdEhxMXBwN3gKT2VZUXV3ZlN0U2s4RkN3LzZ4LzZLMWl3RXhvVXZ5TUhPd1FwTWVhNFU2WVl6SFlWUXU2STNNNjFPdmFMQS9wZgpwUmdrVEZvM1lXSmdJQTIyZXRYZEhXS1gxNTBMbVFJREFRQUJBb0lDQUJmUmI2U1QvYzlsa0R5ZHRXMS9VN1VMCjJPYWZZbkxGcEViVC8zSUQ5RVZiMllYK2NpcDRSZElScWlXUkJKQ0NFU3RHcnNod0tvQzNKU1JxaG1RM0Q0TWUKdDNRRUpRL1psQlBSdmlVeU1BcmFpRmcvTTJpanRDR0JHcWpzRkNDYUpreGE4cDFJSUU1Y2g3OStuTkJRdFQzMwoyb3NMaWsvMTd5ZXN1YXRlN0pPT1NkK0xDdjNXVlVDSUJTSXBOemRITW4rWTBPck5BZUljOC9mT1BLOXRpcGJHCjhhWHVPN3RNb2c5clVmZDZNcy8wQUkrbk9paXY2NkFqbDd0UkZXd3Uwb1M2V0M3Z1hpVkZnZ2lzTHVKbHkrMHQKY3NmOCtnazc2Z0RVbUZXVGdGQVpjWCs4MHp4c28zaEk2Z1BTc1pwL0ZnWHN0QTF1WktaRWpPemZBSUw1SG1OSwpsRmxYRmxpbDhDUDBreFppQTRqMGtpNUZodzRFTEkxdUFJeEY1NE0yazczVjVLdDgycVd0NTNJbzRjTi9xUzVBCnRCbnJjdWJaQjJmaDZDYkhmNTN4ZjNnZm5JMzlNbG82VzdCY2JiMWJGb1ZTbm5CYXhoWGtJZHZRVUV0Z2JZY2cKSlo2SGdwN1B2Y2k4azkzSUtCeW0ycmtvNGNTYitiT0VLTmFhU0drMWVRd0NNZU80WlNadjJDS2Q4bEdMem1CcgpTU1BtTkVPTEdBSEphRi81MzZ1aDRyQVk1RVBOc0N6VUpIa2VYbGdGMTlDNVlrQVVZTVVBeFFRSlJXRUY1VVYwCkxtT2lKOGVPMFI3d2J2Q2RaUGtGazB6cERJS3hrNVNVc0hDU0lFVE5JS3Y2V3ZXT0JLbmFuWStCVzFwRGpJN28KV1o4RktBckRIN1Q4S0txbHFrSHRBb0lCQVFEeWYyTmpRVDVzbkVON0xXUkZsVVBMaTlBNGpwVU1VUWlhakxCQQpqUUFuQ2JqYUs5TzhhNWZwRTEwaHVUY2o1WFZWeEErUzdpMXdpVzQ2VTdCQmJqWlVkd1hIZEl6cnAxZ3Bib2NFCklWL0VDelFiMGRHL2M3NFY1SkFVVVJRenc3QkxXNVcyWXoyc20xTkhoQWtwK3FtSWcrbmhCeWZ2b0hhVTMreXAKZFhoWnN4aXByT1ZHMVEwOEJRTG15anRYUzNVZlUwZytaQU1tblg1MFIvZWpKY0RqdXpqN0FLejUxY0xrazZLMgpjWnZvVGExUWpiNjJCKzhVQUtPdHRxejdJZG9pSnVoMjRTYXExZHBKVVhRTUVueFJIUDV0YUlsdXVZR3ZBYm1DCnphMUsyb3MvVjk3K1ZtMUlxQy9TTjZ0WXRmdmg4SHZBWHlSUCtsWlh0UTloUHFZWEFvSUJBUURscTRzOFBpd3YKQkFVekh5OS8xYW5ETWxreFdXWWZQcXR0eEYrMDVUTXExYllBSVFqeFRKc1BNME1TNk4wVWg5eUdKUzRDZWlWdAozQkFNZEdsUjhIVUE4YTVWYWVBRFpzK3llVG9Qbm1BUk1TMU5SaEJheUVsRG5OY2hrampVQ3BvYWtWQ0RmWDFwCkpWakxIVDZhcy9ueElrS1N1TlB1YzQ3WkNQTk9nMzVDTUNSNGFzK052ZzZ0QUEvNTdldGE5aXVhdFhmV2RHVUIKczlCcVpKSnlvbmhYWnlmMnJjL21ieU1GUmV3TWd2dDdYeUsySmppcWR0MSswTXZYdENSeHlSZ0hBakRGbUswTQp2b1VEcmFSRlZ6ZVRndHdEVTMxSy81d0kxRWZLVy91OU9jNmYwa0Ewb0I3bU1XU2gyNzJhUERYbVFSSEVGT2NLCitxS1NQWlB3ZDVuUEFvSUJBR0lJT2FlZ2NwbjV1aFlMemFPTHFqS1pQUDRBTmlVYWhUM2xia05LUFN1SzlKM08KWmZTZ0VuTjVEb2RabHY3OS9pZEQ4WC9XcGF2L0F2NjFZbVd4Sm1tVERGVUx1d1J4VEdURGQvV2xnRStDci9nbgpKSUlmU2xNVGFXT3RPMXVKMnJVOE94UFdudEl1b01ZaWpJblorYnRraUtJZUFIa1JCNTg3dnpMcWVGTGE0amVGCjI5Sjh3ckxtMjd0dE9md2FWeWpveENYa3pKbEp4aHRBRk01eHJyN2hxekZkbnBBSmFKWjdVS1lzMjNoWUhwNlkKRHVjTDRneldEVlZtcWh1RUhlajhqYkd4WjY1Y2NiaCtJMG5XRjBlN1R1ZndBTTh3VTBycWlaSmxqNDdaTnIzTwp5aWxMeXpZNk44cm1FbkQwY1BWd0FMZE9QeUhOOUNYVTNualRtTlVDZ2dFQkFPTnc5MGpvZFE3MlYwUGlIVUxtClQrRExTb0xSZW8xMG5ZWHRrNjNyMExrWnZNdng2dzR6QTllUXQxclJtcWFMU1ByYmRPM2xFbzN5QVQ2a1JleHMKU1NKdk5HckhsNTBtd29hSEFOV1l6S0FaNkRmL0s1RUxpV3BZdHI4N00rWGd2ZTJUZkgxSzE5ZzVzTzRzZnVQcgpXWmpQaWNnTkcydW5xbzRLREJEenJTUlUwcmtoWlh1RC9McWNOallXeEIxbmJaVWZJcGNRMnpwTlhSY1BrK3ZNCk00cXkwR084aXdjemhpWGhzYnBPT0VkYjFsODJDS1hmWXNnRWMrbWdMdnN6M3dTSnljelV2b0xCWmE1WDFqY0oKQVRPbXdzVFVlRjYrTlVLVkhxY3FZbWxwQnRORS9tcGZLMXBoRGJ3d2hWcHBTQ05HeXhZNGNQbHhiVytQWmFNYwpmZ2NDZ2dFQkFMV1VFUnRYRnYrZGtCcXVJS2JvU0duOWduUHV6eitTWXBQbGQyTjNHdThjeSsyMnRHS21ZQUFpCkJuNmJWc2ltMWZSR1FhTzhzT2U5cGQ4T25pS2YySFVVZDl5WElQeTc4S1lDRGdLaXM1T2xrOHUwYmRPaFNmY1gKQVFJYzJld2ZBNVJ5U1hxWkZQWjU3ekc1a241aUE1alp4dDl5WDd0T2lZNVREdkx6NTFpWUFZbXZhazkwcW1YTwpSN1hqckx0NUlkZE56ZzVOTUFZMTJHZGVOL3NUTHdtQzZQWWpDd244MXlUQ0RXTTNrMUFRc3BUTDhDOGZxL2paCndNQkszd3BDR3h6d1FUTHU4U3lNc1JOZDZHOFVUbkdjcEVERlBtOG14YndjQVI5VGg3Q1A5MjJyTHhwWHNmVkMKTWpTbmoxRkQ2azB6eVBvME1tZ0RzcCtBUUlLY3E2TT0KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQ==
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUEyWTV5NDJoZ1VBclZYcFdSM1dyNQphYUdFMmZDeU1GQXNadWpJVGd1U0F1NFZzQUZ1MFQxcUtKVzMrTEVMeXdDODNSQ0ZqVzUySVdJbUtVNFBUQld1CjhWZ0txSnRRdmcweTVLQ2dxWStDMzZqRlZjcjMwdDZZaVhzMC9DTWxZWE5QK2NQT3hVTXJjSFNzK1k0TkJUcFIKOGJJcWExT2xOclNUNmJ0VVVGOE1VNWVVNG1JUThZYVdCdmVhZG5mY1ZwSzB0WU0yTHZPU2dRdjBuQkcyNnFMWApjWjlTNmgvUVl3OGtFdU5UMXVjMmp5ekx5ZWNFaHpwWTJBdjcwb0NMTWV5ZGkxdm9OYTU1SjQ1Zk12NWc3UlFaClhmNDI3UjN2bUZCU3FQL0tzM1l6dUkraGtCN3U0ZjAybGpsaUcrMDJUNWI2YnpQNzcvWE1ibDBZTktrRzlPUDQKQjVadTd5eDkzMjY3M0QycElOREw1MUZGb1lrMWJNZ2JleTNpNU4vSDhUNmowOUFCeFRoZjZWamRWb29NQmxzegordFV1S09pL2tnMzNXUUNzT1JxU3h1cjk3K29WNUk3Z0xoZXplVXRYVi9iRko0c2ZlMk9TU2lZNXBsNkgxK3V2CjREUmJoNXZiNDg3ZytxYmRLY3hpbVU0clZHOUpsTkRzYnovbFZZbG5CQ200K2RDUHVwN2ZXWm1MNHhzZjBVaS8KM3FkM0UrZWNFRnoyNUdHb3N5QVVzK0dOL0wyK3lFejd6ekp1dmlOZ3Rzcy9KM3ZOVUxSNnRhYWU4VG5tRUxzSAowclVwUEJRc1Arc2YraXRZc0JNYUZMOGpCenNFS1RIbXVGT21HTXgyRlVMdWlOek90VHIyaXdQNlg2VVlKRXhhCk4yRmlZQ0FOdG5yVjNSMWlsOWVkQzVrQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==

转换 private key 和 public key 为 CryptoKey Pairs

以上小节了解了如何生成私钥和公钥。现在我们需要将 base64 编码的私钥和公钥转换成 CryptoKey。 在utils文件夹下,新建convertCryptoKey.ts文件并添加如下代码: src/utils/convertCryptoKey.ts

function removeLines(str: string) {
  return str.replace("\n", "");
}

function base64ToArrayBuffer(b64: string) {
  const byteString = atob(b64);
  const byteArray = new Uint8Array(byteString.length);
  for (let i = 0; i < byteString.length; i++) {
    byteArray[i] = byteString.charCodeAt(i);
  }
  return byteArray;
}

function pemToArrayBuffer(pemKey: string, type: "PUBLIC" | "PRIVATE") {
  const b64Lines = removeLines(pemKey);
  const b64Prefix = b64Lines.replace(`-----BEGIN ${type} KEY-----`, "");
  const b64Final = b64Prefix.replace(`-----END ${type} KEY-----`, "");

  return base64ToArrayBuffer(b64Final);
}
export function convertToCryptoKey({
  pemKey,
  type,
}: {
  pemKey: string;
  type: "PUBLIC" | "PRIVATE";
}) {
  if (type === "PRIVATE") {
    return crypto.subtle.importKey(
      "pkcs8",
      pemToArrayBuffer(pemKey, type),
      {
        name: "RSASSA-PKCS1-v1_5",
        hash: { name: "SHA-256" },
      },
      false,
      ["sign"]
    );
  } else if (type === "PUBLIC") {
    return crypto.subtle.importKey(
      "spki",
      pemToArrayBuffer(pemKey, type),
      {
        name: "RSASSA-PKCS1-v1_5",
        hash: { name: "SHA-256" },
      },
      false,
      ["verify"]
    );
  }
}
  • removeLines():接受一个 PEM key 然后消除所有换行符。
  • base64ToArrayBuffer():将 base64 字符串转换成 ArrayBuffer 形式。
  • pemToArrayBuffer():接受一个转换成 base64 形式的 PEM 证书作为参数,然后在代码中将这个 PEM 证书转换成 ArrayBuffer。
  • convertToCryptoKey():将 PEM 证书转换成 CryptoKey 对象,这个对象将用来生成 access token 和 refresh token。

签发和验证 JSON Web Tokens

现在我们已经能使用 Web Crypto API 生成私钥和公钥了,接下来可以生成两个函数:

  • signJwt():使用私钥对令牌进行签名的函数。
  • verifyJWT():使用对应的公钥对 token 进行验证的函数。 在utils文件夹下创建文件jwt.ts文件,并添加如下依赖

src/utils/jwt.ts

import { getNumericDate, create, verify, dotenvConfig } from "../deps.ts";
import type { Payload, Header } from "../deps.ts";
import { convertToCryptoKey } from "./convertCryptoKey.ts";
dotenvConfig({ export: true, path: ".env" });

签发 JWT

以下代码使用了我们保存在.env文件中的私钥和公钥,在文件src/utils/jwt.ts文件下添加如下代码:

src/utils/jwt.ts

export const signJwt = async ({
  user_id,
  token_uuid,
  issuer,
  base64PrivateKeyPem,
  expiresIn,
}: {
  user_id: string;
  token_uuid: string;
  issuer: string;
  base64PrivateKeyPem: "ACCESS_TOKEN_PRIVATE_KEY" | "REFRESH_TOKEN_PRIVATE_KEY";
  expiresIn: Date;
}) => {
  const header: Header = {
    alg: "RS256",
    typ: "JWT",
  };

  const nowInSeconds = Math.floor(Date.now() / 1000);
  const tokenExpiresIn = getNumericDate(expiresIn);

  const payload: Payload = {
    iss: issuer,
    iat: nowInSeconds,
    exp: tokenExpiresIn,
    sub: user_id,
    token_uuid,
  };

  const cryptoPrivateKey = await convertToCryptoKey({
    pemKey: atob(Deno.env.get(base64PrivateKeyPem) as unknown as string),
    type: "PRIVATE",
  });

  const token = await create(header, payload, cryptoPrivateKey!);

  return { token, token_uuid };
};

验证 JWT

以下代码使用了我们保存在.env文件中的私钥和公钥,在文件src/utils/jwt.ts文件下添加如下代码:

src/utils/jwt.ts

export const verifyJwt = async <T>({
  token,
  base64PublicKeyPem,
}: {
  token: string;
  base64PublicKeyPem: "ACCESS_TOKEN_PUBLIC_KEY" | "REFRESH_TOKEN_PUBLIC_KEY";
}): Promise<T | null> => {
  try {
    const cryptoPublicKey = await convertToCryptoKey({
      pemKey: atob(Deno.env.get(base64PublicKeyPem) as unknown as string),
      type: "PUBLIC",
    });

    return (await verify(token, cryptoPublicKey!)) as T;
  } catch (error) {
    console.log(error);
    return null;
  }
};

Authentication Route Handlers

在本节中,我们将尝试实现处理验证 JWT 的路由配置。首先我们需要在src目录下创建controllers文件夹,在src/controllers文件夹下创建文件auth.controller.ts,然后引入如下模块: src/controllers/auth.controller.ts

import { ObjectId, RouterContext } from "../deps.ts";
import { Bson } from "../deps.ts";
import { User } from "../models/user.model.ts";
import { signJwt, verifyJwt } from "../utils/jwt.ts";
import redisClient from "../utils/connectRedis.ts";

const ACCESS_TOKEN_EXPIRES_IN = 15;
const REFRESH_TOKEN_EXPIRES_IN = 60;

我们创建了ACCESS_TOKEN_EXPIRES_INREFRESH_TOKEN_EXPIRES_IN两个常量来定义 access token 和 refresh token 的过期时间。

用户注册接口

接下来我们创建一个 router handler 来注册用户。当外部发起 HTTP 请求到/api/auth/register这个 endpoint 的时候会使用这个 middleware。

我们会将 request body 转换为一个 JSON Object,然后调用函数User.insertOne()将这个 JSON Object 保存到数据库。函数User.insertOne()会将 user 对象保存到数据库,然后返回这个对象的 ObjectId。

src/controllers/auth.controller.ts

const signUpUserController = async ({
  request,
  response,
}: RouterContext<string>) => {
  try {
    const {
      name,
      email,
      password,
    }: { name: string; email: string; password: string } = await request.body()
      .value;

    const createdAt = new Date();
    const updatedAt = createdAt;

    const userId: string | Bson.ObjectId = await User.insertOne({
      name,
      email: email.toLowerCase(),
      password,
      createdAt,
      updatedAt,
    });

    if (!userId) {
      response.status = 500;
      response.body = { status: "error", message: "Error creating user" };
      return;
    }

    const user = await User.findOne({ _id: userId });

    response.status = 201;
    response.body = {
      status: "success",
      user,
    };
  } catch (error) {
    if ((error.message as string).includes("E11000")) {
      response.status = 409;
      response.body = {
        status: "fail",
        message: "A user with that email already exists",
      };
      return;
    }
    response.status = 500;
    response.body = { status: "error", message: error.message };
    return;
  }
};

因为insertOne()方法只返回 ObjectId,为了在接口中返回完整的对象,我们需要调用User.findOne()方法返回整个 user 对象。

用户登录接口

接下来我们创建一个 router handler 来登录用户。当外部发起 HTTP 请求到/api/auth/login这个 endpoint 的时候会使用这个 middleware。

我们会将从 request body 中获取 email 和 password,然后查询数据库查看该邮件是否存在。 如果用户存在,我们会调用signJwt()方法生成 access token 和 refresh token。然后,我们会将 access token 和 refresh token 的一些信息保存到 Redis 数据库中。

src/controllers/auth.controller.ts

const loginUserController = async ({
  request,
  response,
  cookies,
}: RouterContext<string>) => {
  try {
    const { email, password }: { email: string; password: string } =
      await request.body().value;

    const message = "Invalid email or password";
    const userExists = await User.findOne({ email: email.toLowerCase() });
    if (!userExists) {
      response.status = 401;
      response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const accessTokenExpiresIn = new Date(
      Date.now() + ACCESS_TOKEN_EXPIRES_IN * 60 * 1000
    );
    const refreshTokenExpiresIn = new Date(
      Date.now() + REFRESH_TOKEN_EXPIRES_IN * 60 * 1000
    );

    const { token: access_token, token_uuid: access_uuid } = await signJwt({
      user_id: String(userExists._id),
      token_uuid: crypto.randomUUID(),
      base64PrivateKeyPem: "ACCESS_TOKEN_PRIVATE_KEY",
      expiresIn: accessTokenExpiresIn,
      issuer: "website.com",
    });
    const { token: refresh_token, token_uuid: refresh_uuid } = await signJwt({
      user_id: String(userExists._id),
      token_uuid: crypto.randomUUID(),
      base64PrivateKeyPem: "REFRESH_TOKEN_PRIVATE_KEY",
      expiresIn: refreshTokenExpiresIn,
      issuer: "website.com",
    });

    await Promise.all([
      redisClient.set(access_uuid, String(userExists._id), {
        ex: ACCESS_TOKEN_EXPIRES_IN * 60,
      }),
      redisClient.set(refresh_uuid, String(userExists._id), {
        ex: REFRESH_TOKEN_EXPIRES_IN * 60,
      }),
    ]);

    cookies.set("access_token", access_token, {
      expires: accessTokenExpiresIn,
      maxAge: ACCESS_TOKEN_EXPIRES_IN * 60,
      httpOnly: true,
      secure: false,
    });
    cookies.set("refresh_token", refresh_token, {
      expires: refreshTokenExpiresIn,
      maxAge: REFRESH_TOKEN_EXPIRES_IN * 60,
      httpOnly: true,
      secure: false,
    });

    response.status = 200;
    response.body = { status: "success", access_token };
  } catch (error) {
    response.status = 500;
    response.body = { status: "error", message: error.message };
    return;
  }
};

access token 和 refresh token 会添加到 response cookies 对象,然后作为 HTTPOnly cookies 发送给客户端。 最后,用户可以将这个 token 添加到 Authorization Header 中访问有访问权限的其他请求。

Refresh Access Token 接口

接下来我们创建一个 API/api/auth/refresh用来刷新 access token。 我们需要从请求的 cookie 中拿到 refresh token。然后调用verifyJwt()方法来检查 token。 如果 refresh token 验证通过,我们会请求 Redis 检查数据这个 token 的数据是否仍然存在。如果用户拥有一个有效的 session,我们会调用User.findOne()方法来检查拥有这个 token 的用户是否仍然存在。

const refreshAccessTokenController = async ({
  response,
  cookies,
}: RouterContext<string>) => {
  try {
    const refresh_token = await cookies.get("refresh_token");

    const message = "Could not refresh access token";

    if (!refresh_token) {
      response.status = 403;
      response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const decoded = await verifyJwt<{ sub: string; token_uuid: string }>({
      token: refresh_token,
      base64PublicKeyPem: "REFRESH_TOKEN_PUBLIC_KEY",
    });

    if (!decoded) {
      response.status = 403;
      response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const user_id = await redisClient.get(decoded.token_uuid);

    if (!user_id) {
      response.status = 403;
      response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const user = await User.findOne({ _id: new ObjectId(user_id) });

    if (!user) {
      response.status = 403;
      response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const accessTokenExpiresIn = new Date(
      Date.now() + ACCESS_TOKEN_EXPIRES_IN * 60 * 1000
    );

    const { token: access_token, token_uuid: access_uuid } = await signJwt({
      user_id: decoded.sub,
      issuer: "website.com",
      token_uuid: crypto.randomUUID(),
      base64PrivateKeyPem: "ACCESS_TOKEN_PRIVATE_KEY",
      expiresIn: accessTokenExpiresIn,
    });

    await redisClient.set(access_uuid, String(user._id), {
      ex: ACCESS_TOKEN_EXPIRES_IN * 60,
    });

    cookies.set("access_token", access_token, {
      expires: accessTokenExpiresIn,
      maxAge: ACCESS_TOKEN_EXPIRES_IN * 60,
      httpOnly: true,
      secure: false,
    });

    response.status = 200;
    response.body = { status: "success", access_token };
  } catch (error) {
    response.status = 500;
    response.body = { status: "error", message: error.message };
    return;
  }
};

以上代码中,我们从 Oak state object 中获取user_id,使用这个user_id查询数据库中对应的用户,然后返回生成一个新的 access token 给到客户端。

权限检查 Middleware

接下来我们使用一个 middleware 来检查携带 access token 的请求是否有权限访问对应的接口,以决定是否执行后续的流程。

src目录下新建一个文件middleware/requireUser.ts,并添加如下代码。为了使得鉴权流程更容易扩展,我们在这个 middleware 中从两个地方获取 access token,分别是Authorization HeaderRequest Cookies

src/middleware/requireUser.ts

import { Context, ObjectId } from "../deps.ts";
import { User } from "../models/user.model.ts";
import redisClient from "../utils/connectRedis.ts";
import { verifyJwt } from "../utils/jwt.ts";

const requireUser = async (ctx: Context, next: () => Promise<unknown>) => {
  try {
    const headers: Headers = ctx.request.headers;
    const authorization = headers.get("Authorization");
    const cookieToken = await ctx.cookies.get("access_token");
    let access_token;

    if (authorization) {
      access_token = authorization.split(" ")[1];
    } else if (cookieToken) {
      access_token = cookieToken;
    }

    if (!access_token) {
      ctx.response.status = 401;
      ctx.response.body = {
        status: "fail",
        message: "You are not logged in",
      };
      return;
    }

    const decoded = await verifyJwt<{ sub: string; token_uuid: string }>({
      token: access_token,
      base64PublicKeyPem: "ACCESS_TOKEN_PUBLIC_KEY",
    });

    const message = "Token is invalid or session has expired";

    if (!decoded) {
      ctx.response.status = 401;
      ctx.response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const user_id = await redisClient.get(decoded.token_uuid);

    if (!user_id) {
      ctx.response.status = 401;
      ctx.response.body = {
        status: "fail",
        message,
      };
      return;
    }

    const userExists = await User.findOne({ _id: new ObjectId(user_id) });

    if (!userExists) {
      ctx.response.status = 401;
      ctx.response.body = {
        status: "fail",
        message: "The user belonging to this token no longer exists",
      };
      return;
    }

    ctx.state["user_id"] = userExists._id;
    ctx.state["access_uuid"] = decoded.token_uuid;
    await next();
    delete ctx.state.user_id;
    delete ctx.state.access_uuid;
  } catch (error) {
    ctx.response.status = 500;
    ctx.response.body = {
      status: "fail",
      message: error.message,
    };
  }
};

export default requireUser;

以上代码中:

  • 我们首先从Authorization header或者Request Cookies获取到 access token。
  • 然后,使用在.env文件中的公钥并调用verifyJwt()方法来验证 access token。
  • 然后,请求 Redis 数据库检查 access token 对用的元数据是否存在,如果存在,Redis 会返回 access token 对应的用户的 ID。
  • 之后,我们使用这个用户的 ID 查询 MongoDB 中是否仍然存在这个用户。
  • 如果在鉴权的过程中没有任何错误,在调用下一个 middleware 来继续处理这个请求之前,用户的 id 和 access_uuid 将会被保存到 oak state context 中。

API 路由

现在我们已经开发完成了授权和鉴权相关的 API 和 middleware,现在我们可以创建一个 API 路由来将对应的请求路由到相关的 API 和 middleware 中。

src/routes/auth.routes.ts

import { Router } from "../deps.ts";
import authController from "../controllers/auth.controller.ts";
import requireUser from "../middleware/requireUser.ts";

const router = new Router();

router.post<string>("/register", authController.signUpUserController);
router.post<string>("/login", authController.loginUserController);

router.get<string>("/logout", requireUser, authController.logoutController);

router.get<string>("/refresh", authController.refreshAccessTokenController);

export default router;

以上代码中,当调用/logout这个 API 的时候就需要保证之后已经授权的用户才能访问这个 API。 接下来创建一个关于用户管理的路由,在src/routes文件夹下创建一个user.routes.ts文件并添加以下代码。 src/routes/user.routes.ts

import { Router } from "../deps.ts";
import userController from "../controllers/user.controller.ts";
import requireUser from "../middleware/requireUser.ts";

const router = new Router();

router.get<string>("/me", requireUser, userController.getMeController);

export default router;

我们使用/me这个 API 来返回携带 access token 的请求对应的用户信息。 最后,创建一个init()方法来注册这些路由,所以我们在src/routes文件夹下创建一个index.ts文件,并添加如下代码

src/routes/index.ts

import { Application } from "../deps.ts";
import authRouter from "./auth.routes.ts";
import userRouter from "./user.routes.ts";

function init(app: Application) {
  app.use(authRouter.prefix("/api/auth/").routes());
  app.use(userRouter.prefix("/api/users/").routes());
}

export default {
  init,
};

在 oak 中注册路由

接下来在src/server.ts文件中使用以下代码

src/server.ts

import { Application, Router, logger, oakCors } from "./deps.ts";
import type { RouterContext } from "./deps.ts";
import appRouter from "./routes/index.ts";

const app = new Application();
const router = new Router();

// Middleware Logger
app.use(logger.default.logger);
app.use(logger.default.responseTime);

// Health checker
router.get<string>("/api/healthchecker", (ctx: RouterContext<string>) => {
  ctx.response.status = 200;
  ctx.response.body = {
    status: "success",
    message: "JWT Refresh Access Tokens in Deno with RS256 Algorithm",
  };
});

app.use(
  oakCors({
    origin: /^.+localhost:(3000|3001)$/,
    optionsSuccessStatus: 200,
    credentials: true,
  })
);

appRouter.init(app);
app.use(router.routes());
app.use(router.allowedMethods());

app.addEventListener("listen", ({ port, secure }) => {
  console.info(
    `🚀 Server started on ${secure ? "https://" : "http://"}localhost:${port}`
  );
});

const port = 8000;
app.listen({ port });

最后,确认 Redis 和 MongoDB 数据库对应的 container 都正常启动之后,执行以下命令启动 Deno 服务器。

denon run --allow-net --allow-read --allow-write --allow-env src/server.ts

测试 JWT 授权 API

接下来将使用 Postman 来测试 Deno 的 API。

注册用户

使用以下的请求内容并发送一个 POST 请求到http://localhost:8000/api/auth/register

{
  "email": "admin@admin.com",
  "name": "Admin",
  "password": "password123",
  "passwordConfirm": "password123"
}

Deno API 会接受到请求,并从请求内容中获取以上注册信息,并新建一个 user document 保存到数据库中。之后这个 API 会返回一个包含新建的 document 的 response。

截屏2023-01-15 10.54.43.png

用户登录

使用以下的请求内容并发送一个 POST 请求到http://localhost:8000/api/auth/login

{
  "email": "admin@admin.com",
  "password": "password123"
}

身份验证成功之后,server 会使用存储在.env 文件中的私钥生成 access token 和 refresh token。并将它们作为 HTTP Only cookie 返回给客户端。

截屏2023-01-15 11.31.47.png

为了保证可扩展性,服务端会将 access token 也添加到 response body 中返回给客户端,客户端可以将 access token 添加到 Authorization header 中。

获取 Profile 信息

在请求这个 API 获取用户 profile 信息之前必须要有一个有效的 access token。这意味着,/api/users/me这个 API 是受保护的,只有已经授权的用户才能访问。

当登录之后,服务端会返回一个 access token,可以使用这个 access token 来访问受保护的 API。为了获取账号的用户信息,可以在 postman 中发送一个 GET 请求到http://localhost:8000/api/users/me,postman 会自动为这个请求添加 cookies。 同样的,你也可以访问登录的 API 获取 access token 之后,将这个 access token 添加到 Headers 中,header 的 name 即 Authorization。

然后,server 会从 Authorization header 或 Cookies 对象中提取 access token,并根据存储在 .env 文件中的公钥对其进行验证。

截屏2023-01-15 11.38.32.png

一旦校验通过之后,在从 MongoDB 获取用户信息并返回之前,会执行其他的身份验证方法。

Refresh Access Token

为了从 API 中获取一个新的 access token,先要确保有一个有效的 refresh token cookie,然后向 APIhttp://localhost:8000/api/auth/refresh发起一个 GET 请求。 服务端会从 cookie 中获取 refresh token,然后使用保存在.env文件中的公钥来校验。

截屏2023-01-15 12.25.00.png

如果 refresh token 校验通过,服务端会生成一个新的 access token,并添加为 HTTP Only cookie。server 同时也会将 access token 添加到 response body 中给偏向使用 Authorization header 的方案。

Logout User

为了退出登录状态,首先需要保证有一个有效的 access token 并向 API http://localhost:8000/api/auth/logout发起一个 GET 请求。

server 会从 Redis 数据库中将用户的 session 删除,并发送 expired cookies 去删掉在客户端存在的 cookie。

截屏2023-01-15 12.30.08.png

结论

在这片文章中,我们尝试了在 Deno 中使用 refresh access token 的方式。另外,我们也尝试了如何使用 Web Crypto API 生成私钥和公钥。

你可以在 GitHub 上找到本项目的完整源代码。