使用 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
现在,您可以通过向服务器GET发送请求来测试您的服务器http://localhost:5000/api/v1/。
步骤 2:安装依赖项并设置环境变量
npm install -dev prisma
npm install @prisma/client bcrypt jsonwebtoken
npx prisma init --datasource-provider sqlite
在里面添加以下内容.env。
JWT_ACCESS_SECRET=SECRET123
步骤 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
}
npx prisma migrate dev然后在控制台中运行。
现在,我们已经准备好编写身份验证逻辑了。
我们将使用两个表。用户表的功能显而易见。刷新令牌表将用作白名单,用于存放我们根据第一部分所述生成的令牌。
步骤 4:添加实用函数。
创建一个名为utils“inside”的文件夹src。我们将在此文件夹中添加以下文件:
- db.js - 用于与 Prisma 进行数据库交互。
const { PrismaClient } = require('@prisma/client');
const db = new PrismaClient();
module.exports = { db };
- 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,
};
- hashToken.js - 用于在将令牌保存到数据库之前对其进行哈希处理。
const crypto = require('crypto');
function hashToken(token) {
return crypto.createHash('sha512').update(token).digest('hex');
}
module.exports = { hashToken };
第五步:项目结构
删除 emojis.js 文件,src/api并api/index.js通过移除 emojis 路由进行清理。
创建两个文件夹:auth和users。src/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
步骤 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,
};
大部分代码都是不言自明的,但总而言之,我们定义了一些特定于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,
};
现在,我们已经准备好编写路线了。
步骤 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;
在这里,我们获取用户的邮箱/密码。我们进行一些基本的验证(您需要在此处添加一些验证步骤,例如使用yup`or` joi)。我们创建用户、令牌,并将刷新令牌添加到白名单(流程图请参见图 1)。
为了使我们的应用程序能够识别此路由,我们需要在其中添加一些代码src/api/index.js:
const auth = require('./auth/auth.routes');
router.use('/auth', auth);
现在您可以通过向该端点发送 POST 请求来测试它http://localhost:5000/api/v1/auth/register。响应将是:
{
"accessToken": "generatedAccessToken...",
"refreshToken": "generatedRefreshToken..."
}
接下来我们来看登录接口。这个接口和注册接口非常相似。
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);
}
});
现在您可以通过向登录端点发送 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;
不应/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
}
我们检查客户端是否发送了请求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;
里面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;
现在,您可以GET向该端点发出请求http://localhost:5000/api/v1/users/profile。您需要添加一个Authorization包含从该端点获取的访问令牌的标头/login。
就这样啦!🎉🎉🎉
文章来源:https://dev.to/mihaiandrei97/jwt-authentication-using-prisma-and-express-37nk


