发布于 2026-01-06 2 阅读
0

使用 Prisma 和 Express 进行 JWT 身份验证

使用 Prisma 和 Express 进行 JWT 身份验证

所有文章均首先发布在我的网站上。

两年前我写了一篇博客文章,开头是这样的:

经过长时间的研究,我终于找到了一个令我满意的身份验证工作流程实现方案。请注意,我认为这个实现方案并不完美,但就我的使用场景而言,它完全够用。此外,本文不涉及任何验证或电子邮件验证,仅讲解 JWT Token 的实现。

两年后,我的看法有所改变。当时,我使用刷新令牌作为 JWT。为什么?因为我就是这么学的。现在,我认为刷新令牌可以是随机字符串。你可以把刷新令牌理解为会话 ID。会话信息会在数据库中查询。访问令牌(JWT)的作用仅仅是作为一种“缓存机制”,避免每次请求都访问数据库。

虽然我提倡尽可能使用仅基于 HTTP Cookie 的会话身份验证,但在某些情况下,例如在微服务架构中,这种方法可能难以实施。如果您需要实现 JWT 身份验证,让我来为您介绍一种改进的方法。

第一部分:工作流程

我们将实现以下接口:

终点

对于/auth/login`<username>` 和 ` /auth/register<refreshToken>`,客户端需要提供用户名和密码以换取一对令牌(访问令牌和刷新令牌)。
使用访问令牌,可以向 `<username>` 发出请求/users/profile。此处将应用以下工作流程:

访问令牌

注意:我们只检查令牌是否有效。这样可以保持工作流的无状态性。因此,访问令牌应该很快过期(5-10分钟)。

为了保持用户登录状态,客户端需要发送一个/auth/refreshToken包含注册/登录时收到的 refreshToken 的请求。
服务器会根据该令牌进行一些检查,并提供一对新的令牌。该过程如下图所示。

刷新令牌

现在,我们进入编码部分。

第二部分代码

该实现的代码可以在这里找到

注意:我的建议是将两个令牌都存储在仅限 HTTP 的 Cookie中。如果由于前端没有后端支持( BFF)而无法做到这一点,您可以将访问令牌存储在内存中,将刷新令牌存储在本地存储中。只需确保您的前端逻辑能够抵御 XSS 攻击即可。

步骤 1:创建应用程序

当我在后端编写 JavaScript 代码时,我更喜欢使用Coding Garden提供的样板代码。
要使用 CJ 的样板代码,我们可以从终端运行以下代码。

npx create-express-api -d auth-server
cd auth-server
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

现在,您可以通过向服务器GET发送请求来测试您的服务器http://localhost:5000/api/v1/

步骤 2:安装依赖项并设置环境变量

npm install -dev prisma
npm install @prisma/client bcrypt jsonwebtoken
npx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

在里面添加以下内容.env

JWT_ACCESS_SECRET=SECRET123
Enter fullscreen mode Exit fullscreen mode

步骤 3:Prisma 设置

在这里prisma/schema.prisma,我们将定义数据库模型。请粘贴以下内容。

model User {
  id            String         @id @unique @default(uuid())
  email         String         @unique
  password      String
  refreshTokens RefreshToken[]
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt
}

model RefreshToken {
  id          String   @id @unique @default(uuid())
  hashedToken String   @unique
  userId      String
  User        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  revoked     Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  expireAt    DateTime
}
Enter fullscreen mode Exit fullscreen mode

npx prisma migrate dev然后在控制台中运行。

现在,我们已经准备好编写身份验证逻辑了。

我们将使用两个表。用户表的功能显而易见。刷新令牌表将用作白名单,用于存放我们根据第一部分所述生成的令牌。

步骤 4:添加实用函数。

创建一个名为utils“inside”的文件夹src。我们将在此文件夹中添加以下文件:

  • db.js - 用于与 Prisma 进行数据库交互。
const { PrismaClient } = require('@prisma/client');

const db = new PrismaClient();

module.exports = { db };
Enter fullscreen mode Exit fullscreen mode
  • jwt.js - 用于生成令牌。
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Usually I keep the token between 5 minutes - 15 minutes
function generateAccessToken(user) {
  return jwt.sign({ userId: user.id }, process.env.JWT_ACCESS_SECRET, {
    expiresIn: '5m',
  });
}

// Generate a random string as refreshToken
function generateRefreshToken() {
  const token = crypto.randomBytes(16).toString('base64url');
  return token;
}

function generateTokens(user) {
  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken();
  return { accessToken, refreshToken };
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  generateTokens,
};

Enter fullscreen mode Exit fullscreen mode
  • hashToken.js - 用于在将令牌保存到数据库之前对其进行哈希处理。
const crypto = require('crypto');

function hashToken(token) {
  return crypto.createHash('sha512').update(token).digest('hex');
}

module.exports = { hashToken };

Enter fullscreen mode Exit fullscreen mode

第五步:项目结构

删除 emojis.js 文件,src/apiapi/index.js通过移除 emojis 路由进行清理。

创建两个文件夹:authuserssrc/api在每个文件夹中,我们将创建两个文件,分别用于路由和服务。

 ┣ 📂src
 ┃ ┣ 📂api
 ┃ ┃ ┣ 📂auth
 ┃ ┃ ┃ ┣ 📜auth.routes.js
 ┃ ┃ ┃ ┗ 📜auth.services.js
 ┃ ┃ ┣ 📂users
 ┃ ┃ ┃ ┣ 📜users.routes.js
 ┃ ┃ ┃ ┗ 📜users.services.js
 ┃ ┃ ┗ 📜index.js
 ┃ ┣ 📂utils
 ┃ ┃ ┣ 📜db.js
 ┃ ┃ ┣ 📜hashToken.js
 ┃ ┃ ┣ 📜jwt.js
 ┃ ┃ ┗ 📜sendRefreshToken.js
Enter fullscreen mode Exit fullscreen mode

步骤 6:服务

现在,user.services.js将以下代码粘贴到里面:

const bcrypt = require('bcrypt');
const { db } = require('../../utils/db');

function findUserByEmail(email) {
  return db.user.findUnique({
    where: {
      email,
    },
  });
}

function createUserByEmailAndPassword(user) {
  user.password = bcrypt.hashSync(user.password, 12);
  return db.user.create({
    data: user,
  });
}

function findUserById(id) {
  return db.user.findUnique({
    where: {
      id,
    },
  });
}

module.exports = {
  findUserByEmail,
  findUserById,
  createUserByEmailAndPassword,
};

Enter fullscreen mode Exit fullscreen mode

大部分代码都是不言自明的,但总而言之,我们定义了一些特定于User我们将在项目中使用的表的辅助函数。

现在,是以下代码auth.services.js

const { db } = require('../../utils/db');
const { hashToken } = require('../../utils/hash');

// used when we create a refresh token.
// a refresh token is valid for 30 days
// that means that if a user is inactive for more than 30 days, he will be required to log in again
function addRefreshTokenToWhitelist({ refreshToken, userId }) {
  return db.refreshToken.create({
    data: {
      hashedToken: hashToken(refreshToken),
      userId,
      expireAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days
    },
  });
}

// used to check if the token sent by the client is in the database.
function findRefreshToken(token) {
  return db.refreshToken.findUnique({
    where: {
      hashedToken: hashToken(token),
    },
  });
}

// soft delete tokens after usage.
function deleteRefreshTokenById(id) {
  return db.refreshToken.update({
    where: {
      id,
    },
    data: {
      revoked: true,
    },
  });
}

function revokeTokens(userId) {
  return db.refreshToken.updateMany({
    where: {
      userId,
    },
    data: {
      revoked: true,
    },
  });
}

module.exports = {
  addRefreshTokenToWhitelist,
  findRefreshToken,
  deleteRefreshTokenById,
  revokeTokens,
};

Enter fullscreen mode Exit fullscreen mode

现在,我们已经准备好编写路线了。

步骤 7:身份验证路由。

我们来创建/register端点。在端点内auth.routes.js放入以下代码:

const express = require('express');
const bcrypt = require('bcrypt');
const { generateTokens } = require('../../utils/jwt');
const {
  addRefreshTokenToWhitelist,
  findRefreshToken,
  deleteRefreshTokenById,
  revokeTokens,
} = require('./auth.services');

const router = express.Router();
const {
  findUserByEmail,
  createUserByEmailAndPassword,
  findUserById,
} = require('../user/user.services');

router.post('/register', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      res.status(400);
      throw new Error('You must provide an email and a password.');
    }

    const existingUser = await findUserByEmail(email);

    if (existingUser) {
      res.status(400);
      throw new Error('Email already in use.');
    }

    const user = await createUserByEmailAndPassword({ email, password });
    const { accessToken, refreshToken } = generateTokens(user);
    await addRefreshTokenToWhitelist({ refreshToken, userId: user.id });

    res.json({
      accessToken,
      refreshToken,
    });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

在这里,我们获取用户的邮箱/密码。我们进行一些基本的验证(您需要在此处添加一些验证步骤,例如使用yup`or` joi)。我们创建用户、令牌,并将刷新令牌添加到白名单(流程图请参见图 1)。
为了使我们的应用程序能够识别此路由,我们需要在其中添加一些代码src/api/index.js

const auth = require('./auth/auth.routes');
router.use('/auth', auth);
Enter fullscreen mode Exit fullscreen mode

现在您可以通过向该端点发送 POST 请求来测试它http://localhost:5000/api/v1/auth/register。响应将是:

{
    "accessToken": "generatedAccessToken...",
    "refreshToken": "generatedRefreshToken..."
}
Enter fullscreen mode Exit fullscreen mode

接下来我们来看登录接口。这个接口和注册接口非常相似。

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      res.status(400);
      throw new Error('You must provide an email and a password.');
    }

    const existingUser = await findUserByEmail(email);

    if (!existingUser) {
      res.status(403);
      throw new Error('Invalid login credentials.');
    }

    const validPassword = await bcrypt.compare(password, existingUser.password);
    if (!validPassword) {
      res.status(403);
      throw new Error('Invalid login credentials.');
    }

    const { accessToken, refreshToken } = generateTokens(existingUser);
    await addRefreshTokenToWhitelist({ refreshToken, userId: existingUser.id });

    res.json({
      accessToken,
      refreshToken,
    });
  } catch (err) {
    next(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

现在您可以通过向登录端点发送 POST 请求并提供现有的用户名/密码组合来测试登录端点http://localhost:5000/api/v1/auth/login。如果成功,您将收到包含访问令牌和刷新令牌的响应。

接下来,我们将添加refresh_token用于撤销所有令牌的端点和一个测试端点。以下是全部代码auth.routes.ts

const express = require('express');
const bcrypt = require('bcrypt');
const { generateTokens } = require('../../utils/jwt');
const {
  addRefreshTokenToWhitelist,
  findRefreshToken,
  deleteRefreshTokenById,
  revokeTokens,
} = require('./auth.services');

const router = express.Router();
const {
  findUserByEmail,
  createUserByEmailAndPassword,
  findUserById,
} = require('../user/user.services');

router.post('/register', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      res.status(400);
      throw new Error('You must provide an email and a password.');
    }

    const existingUser = await findUserByEmail(email);

    if (existingUser) {
      res.status(400);
      throw new Error('Email already in use.');
    }

    const user = await createUserByEmailAndPassword({ email, password });
    const { accessToken, refreshToken } = generateTokens(user);
    await addRefreshTokenToWhitelist({ refreshToken, userId: user.id });

    res.json({
      accessToken,
      refreshToken,
    });
  } catch (err) {
    next(err);
  }
});

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      res.status(400);
      throw new Error('You must provide an email and a password.');
    }

    const existingUser = await findUserByEmail(email);

    if (!existingUser) {
      res.status(403);
      throw new Error('Invalid login credentials.');
    }

    const validPassword = await bcrypt.compare(password, existingUser.password);
    if (!validPassword) {
      res.status(403);
      throw new Error('Invalid login credentials.');
    }

    const { accessToken, refreshToken } = generateTokens(existingUser);
    await addRefreshTokenToWhitelist({ refreshToken, userId: existingUser.id });

    res.json({
      accessToken,
      refreshToken,
    });
  } catch (err) {
    next(err);
  }
});

router.post('/refreshToken', async (req, res, next) => {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      res.status(400);
      throw new Error('Missing refresh token.');
    }
    const savedRefreshToken = await findRefreshToken(refreshToken);

    if (
      !savedRefreshToken
      || savedRefreshToken.revoked === true
      || Date.now() >= savedRefreshToken.expireAt.getTime()
    ) {
      res.status(401);
      throw new Error('Unauthorized');
    }

    const user = await findUserById(savedRefreshToken.userId);
    if (!user) {
      res.status(401);
      throw new Error('Unauthorized');
    }

    await deleteRefreshTokenById(savedRefreshToken.id);
    const { accessToken, refreshToken: newRefreshToken } = generateTokens(user);
    await addRefreshTokenToWhitelist({ refreshToken: newRefreshToken, userId: user.id });

    res.json({
      accessToken,
      refreshToken: newRefreshToken,
    });
  } catch (err) {
    next(err);
  }
});

// This endpoint is only for demo purpose.
// Move this logic where you need to revoke the tokens( for ex, on password reset)
router.post('/revokeRefreshTokens', async (req, res, next) => {
  try {
    const { userId } = req.body;
    await revokeTokens(userId);
    res.json({ message: `Tokens revoked for user with id #${userId}` });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

不应/revokeRefreshTokens在 API 中公开此方法。您revokeTokens仅应在需要使所有令牌失效的特定情况下调用此方法(例如:密码重置)。
至于refresh_token端点,它用于获取另一对令牌,以便保持用户登录状态。我们会检查发送的刷新令牌是否有效,以及它是否存在于我们的数据库中且未被撤销。如果满足这些条件,我们将使之前的刷新令牌失效并生成一对新的令牌。

步骤 8:受保护的路由。

首先,为了保护我们的路由,我们需要定义一个中间件。请转到src/middlewares.js并添加以下代码:

function isAuthenticated(req, res, next) {
  const { authorization } = req.headers;

  if (!authorization) {
    res.status(401);
    throw new Error('🚫 Un-Authorized 🚫');
  }

  try {
    const token = authorization.split(' ')[1];
    const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
    req.payload = payload;
  } catch (err) {
    res.status(401);
    if (err.name === 'TokenExpiredError') {
      throw new Error(err.name);
    }
    throw new Error('🚫 Un-Authorized 🚫');
  }

  return next();
}

module.exports = {
    // ... other modules
    isAuthenticated
}
Enter fullscreen mode Exit fullscreen mode

我们检查客户端是否发送了请求Authorization头。请求头格式应为:Bearer token。如果请求头中包含令牌,我们会使用我们的密钥对其进行验证,并将其添加到请求中,以便在路由中访问。

注意:我们只检查令牌是否有效。这样,我们的工作流就保持无状态。
:如果用户被删除或刷新令牌失效会发生什么?
:用户仍可访问,直到访问令牌过期。最长访问时间为 5 分钟(这就是为什么我们的访问令牌过期很快)。我认为这适用于大多数应用程序(当然,如果您开发的是银行应用程序,可能不适用。但对于大多数应用程序来说,这都没问题)。例如,假设您开发了一个包含免费和付费内容的应用程序。用户付费购买了 30 天的付费内容。30 天后,您将降低其访问权限,但如果他之前已经拥有令牌,他仍然可以访问内容 5 分钟。基于此逻辑,您可以相应地设置访问令牌的过期时间。

现在,我们来编写受保护的路由。请转到src/api/users/users.routes.js并添加以下代码:

const express = require('express');
const { isAuthenticated } = require('../../middlewares');
const { findUserById } = require('./user.services');

const router = express.Router();

router.get('/profile', isAuthenticated, async (req, res, next) => {
  try {
    const { userId } = req.payload;
    const user = await findUserById(userId);
    delete user.password;
    res.json(user);
  } catch (err) {
    next(err);
  }
});

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

里面src/api/index.js

const express = require('express');
const auth = require('./auth/auth.routes');
const users = require('./user/user.routes');

const router = express.Router();

router.get('/', (req, res) => {
  res.json({
    message: 'API - 👋🌎🌍🌏',
  });
});

router.use('/auth', auth);
router.use('/users', users);

module.exports = router;

Enter fullscreen mode Exit fullscreen mode

现在,您可以GET向该端点发出请求http://localhost:5000/api/v1/users/profile。您需要添加一个Authorization包含从该端点获取的访问令牌的标头/login

就这样啦!🎉🎉🎉

文章来源:https://dev.to/mihaiandrei97/jwt-authentication-using-prisma-and-express-37nk