paint-brush
如何使用 Knex 和 Redis 在 Node.js 中掌握身份验证和用户流程经过@antonkalik
901 讀數
901 讀數

如何使用 Knex 和 Redis 在 Node.js 中掌握身份验证和用户流程

经过 Anton Kalik26m2024/03/17
Read on Terminal Reader

太長; 讀書

使用 Knex 进行数据库管理,使用 Redis 进行高效缓存,使用 Express 进行无缝路由。使用 Knex 和 Redis 创建并了解 Node js 服务器的强大身份验证解决方案。使用 Knex 为 Node.js 创建数据库,然后使用 Redis 进行缓存,使用 Express 路由数据。
featured image - 如何使用 Knex 和 Redis 在 Node.js 中掌握身份验证和用户流程
Anton Kalik HackerNoon profile picture
0-item

使用 Knex 进行数据库管理、Redis 进行高效缓存以及 Express 进行无缝路由,为 Node js 服务器创建强大的身份验证解决方案。

在为 Node.js 应用程序寻求快速、直观且简化的身份验证解决方案时,我遇到了需要快速实施而不影响功能的场景。


从用户注册和登录到管理忘记的密码、更新用户数据,甚至删除帐户,我寻求一种能够无缝导航这些基本用户交互的全面解决方案。


因此,我的文章旨在准确地呈现这一点——一种整合清晰方法论的内聚方法来实现身份验证和缓存,确保强大而高效的用户流程。


在这里,我们将绕过基本的安装过程和模型创建,直接研究复杂的身份验证和用户流程。我们将在整篇文章中包含获取配置文件所需的所有链接,以确保无缝访问设置所需的资源。

工具

对于此实现,我们将利用 Node.js 版本 20.11.1 以及 Knex、Express 和 Redis。此外,我们将利用 PostgreSQL 作为数据库,该数据库将使用 Docker 进行容器化和编排,以实现无缝管理。


我们的应用程序的名称将是user-flow-boilerplate 。让我们创建该文件夹并在内部运行npm init -y生成基本的package.json


 { "name": "user-flow-boilerplate", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }

初始package.json


下一步是添加必要的依赖项:


依赖项npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator


开发依赖项

 npm i -D @babel/core @babel/eslint-parser @babel/plugin-transform-class-properties @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript @faker-js/faker @types/bcrypt @types/body-parser @types/cors @types/express @types/jest @types/jsonwebtoken @types/node @types/node-cron @types/validator @typescript-eslint/eslint-plugin @typescript-eslint/parser babel-jest cross-env eslint eslint-config-prettier eslint-plugin-prettier jest nodemon npm-run-all prettier ts-jest ts-loader ts-node tsconfig-paths tslint typescript webpack webpack-cli webpack-node-externals


并添加将构建和运行我们的应用程序的脚本:

 "scripts": { "start": "NODE_ENV=production node dist/bundle.js", "build": "NODE_ENV=production webpack --config webpack.config.js", "dev": "cross-env NODE_ENV=development && npm-run-all -p dev:*", "dev:build": "webpack --config webpack.config.js --watch", "dev:start": "nodemon --watch dist --exec node dist/bundle.js", "test": "NODE_ENV=test jest --config ./jest.config.js", "lint": "eslint ./src -c .eslintrc.json" },


为了确保我们的应用程序顺利启动,必须创建一个src文件夹,并将我们的初始入口点文件index.ts放置在其中。

 require('dotenv').config(); import process from 'process'; import express from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; const app = express(); const PORT = process.env.PORT || 9999; app.use(bodyParser.json()); app.use(cors()); app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' })); (async () => { try { app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();

入口点文件


对于开发,我们需要对typscriptlintjestbableprettiernodemon进行设置。我在以下文章中描述了所有这些文件:在 Express 上使用 Postgres 和 Knex 创建 Node.js 服务器


配置所有设置并创建入口点后,执行npm run dev应启动服务器,并且您应该会看到类似于以下内容的输出:

 ./src/index.ts 1.7 KiB [built] [code generated] external "dotenv" 42 bytes [built] [code generated] external "process" 42 bytes [built] [code generated] external "express" 42 bytes [built] [code generated] external "body-parser" 42 bytes [built] [code generated] external "cors" 42 bytes [built] [code generated] webpack 5.90.3 compiled successfully in 751 ms [nodemon] restarting due to changes... [nodemon] starting `node dist/bundle.js` Server is running on port 9999


接下来,导航至邮差我们将在其中建立一个专门用于测试我们的端点的集合。在新集合中,添加新的GET请求,按cmd + E (在 Mac 上,但按键取决于您的操作系统),并将其命名为health


添加 URL 输入: {{BASE_URI}}/health 。对于BASE_URI ,添加一个您将在整个集合中使用的新变量: http://localhost:9999/api/v1

邮递员设置基本 URL

然后,只需单击“发送”按钮,您应该会观察响应正文:

 { "message": "OK" }


数据库

在继续之前,启动并运行我们的数据库至关重要。我们将通过使用docker-compose启动它来完成此任务。要访问和管理数据库,您可以利用各种开发平台,例如管理员组


就我个人而言,我更喜欢使用红宝石矿,它配备了一个驱动程序,可以无缝连接到 PostgreSQL 数据库以实现高效管理。


我们需要.env文件,其中包含必要的密钥、密码和测试名称:

 PORT=9999 WEB_HOST="localhost" # DB DB_HOST="localhost" DB_PORT=5432 DB_NAME="user_flow_boilerplate" DB_USER="username_123" DB_PASSWORD="SomeParole999" # User DEFAULT_PASSWORD="SomeParole999" JWT_SECRET="6f1d7e9b9ba56476ae2f4bdebf667d88eeee6e6c98c68f392ed39f7cf6e51c5a" # Test User TEST_EMAIL="[email protected]" TEST_USERNAME="test_username" TEST_PASSWORD="SomeParole999" # Redis REDIS_HOST="localhost" REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD="SomeParole999"

.env 用于连接数据库、Redis 和种子测试值


不用担心,我随机生成了JWT_SECRET以更真实的方式说明它。因此,让我们在项目的根目录创建一个docker-compose.yml文件:

 version: '3.6' volumes: data: services: database: build: context: . dockerfile: postgres.dockerfile image: postgres:latest container_name: postgres environment: TZ: Europe/Madrid POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} networks: - default volumes: - data:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped redis: image: redis:latest container_name: redis command: redis-server --requirepass ${REDIS_PASSWORD} networks: - default ports: - "6379:6379" restart: unless-stopped

带有服务的 docker-compose 文件


我们将在 Docker 中启动两项服务以实现快速连接。我简化了这个过程,以方便快速访问数据库或 Redis,使我们能够高效地检索数据。因此,让我们运行这些服务docker-compose up ,并且我们必须能够在docker ps以下输出之后看到输出:

 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e4bef95de1dd postgres:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:5432->5432/tcp postgres 365e3a68351a redis:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:6379->6379/tcp redis


现在,我们需要创建src/@types/index.ts文件,在其中存储应用程序的类型:

 export enum Role { Admin = 'admin', Blogger = 'blogger', } export type UserSession = { id: number; }; export type DatabaseDate = { created_at: Date; updated_at: Date; }; export type DefaultUserData = { role: Role; }; export interface User extends DatabaseDate { id: number; email: string; username: string; password: string; role: Role; }

服务类型


此时,您需要在项目根目录和数据库文件夹中有knexfile.ts用于连接、迁移和种子。


在 Express 上使用 Postgres 和 Knex 创建 Node.js 服务器一文中留下了非常详细的解释,介绍如何将用户迁移到我们使用这些环境变量的数据库并为其添加种子。


我想专门检查迁移以确保我们处于同一页面上。我们已经启动了我们的服务,并且我们必须能够检查与数据库的连接。

 docker exec -it postgres psql -U username_123 user_flow_boilerplate


如果连接良好,您将进入psql控制台。好的,如果连接没有问题,那么我们应该能够将表迁移到那里。运行knex migrate:latest 。然后您应该观察数据库中users表中新添加的列。

迁移后的用户表

让我们用假数据knex seed:run为其播种,然后再次检查该表。

种子后数据库中的结果

因此,我们现在可以操作数据库,允许我们根据需要添加、删除或更新用户。

路由器

最后,我们可以忘记设置和准备,专门关注用户流程。为此,我们需要创建一个路由器。我们需要通过该路由器处理以下操作: loginlogoutsignupdelete_userupdate_user


为此,在src/routes/index.ts上添加以下代码:

 import { Router } from 'express'; import { authRouter } from 'src/routes/authRouter'; import { healthController } from 'src/controllers/healthController'; import { sessionController } from 'src/controllers/sessionController'; import { authMiddleware } from 'src/middlewares/authMiddleware'; import { userRouter } from 'src/routes/userRouter'; export const router = Router({ mergeParams: true }); router.get('/health', healthController); router.use('/auth', authRouter); router.get('/session', authMiddleware, sessionController); router.use('/user', authMiddleware, userRouter); router.use((_, res) => { return res.status(404).json({ message: 'Not Found' }); });

路线文件


正如你所看到的,一开始我们添加了/health路线,我们已经检查过了。那么,让我们更新入口点以在那里应用这些路由。首先,删除之前的get

 -> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));


并添加到文件顶部:

 import { router } from 'src/routes'; // ... app.use(cors()); app.use('/api/v1', router);


并使用代码创建第一个用于health检查的控制器src/controllers/healthController.ts

 import { Request, Response } from 'express'; export const healthController = (_: Request, res: Response) => res.status(200).send('ok');

健康控制器


现在,让我们回到路由器,检查一下我们必须向路由添加更多内容。我们还需要添加两个文件: authRouter.tsuserRouter.ts

 import { Router } from 'express'; import { signUpController } from 'src/controllers/auth/signUpController'; import { loginController } from 'src/controllers/auth/loginController'; export const authRouter = Router(); authRouter.post('/signup', signUpController); authRouter.post('/login', loginController);

认证路由器


import { Router } from 'express'; import { updateUserController } from 'src/controllers/user/updateUserController'; import { deleteUserController } from 'src/controllers/user/deleteUserController'; import { logoutController } from 'src/controllers/user/logoutController'; import { updatePasswordController } from 'src/controllers/user/updatePasswordController'; export const userRouter = Router(); userRouter.patch('/', updateUserController); userRouter.delete('/', deleteUserController); userRouter.post('/logout', logoutController); userRouter.post('/update-password', updatePasswordController);

用户路由器


为了可读性和维护独立功能的责任,我划分了这个逻辑。所有这些路由都需要控制器来处理逻辑。


控制器


身份验证和健康路由不需要身份验证中间件,因此这些路由不受保护,但如果没有匹配,我们将得到状态 404。

 router.get('/health', healthController); router.use('/auth', authRouter);

现在,我们已经确定了所有的路线,我们必须设置用户模型。

用户模型

我将利用用户模型的基本模型,从中重用 CRUD 方法。虽然我之前在另一个文章中介绍过模型创建文章,我将在此处包含基本模型,以便更好的可见性和理解。在src/models/Model.ts中创建

import { database } from 'root/database'; export abstract class Model { protected static tableName?: string; protected static get table() { if (!this.tableName) { throw new Error('The table name must be defined for the model.'); } return database(this.tableName); } public static async insert<Payload>(data: Payload): Promise<{ id: number; }> { const [result] = await this.table.insert(data).returning('id'); return result; } public static async updateOneById<Payload>( id: number, data: Payload ): Promise<{ id: number; }> { const [result] = await this.table.where({ id }).update(data).returning('id'); return result; } public static async delete(id: number): Promise<number> { return this.table.where({ id }).del(); } public static async findOneById<Result>(id: number): Promise<Result> { return this.table.where('id', id).first(); } public static async findOneBy<Payload, Result>(data: Payload): Promise<Result> { return this.table.where(data as string).first(); } }

基础型号


使用基本模型,我们必须能够在同一文件夹中创建UserModel.ts

 import { Model } from 'src/models/Model'; import { Role, User, DefaultUserData } from 'src/@types'; export class UserModel extends Model { static tableName = 'users'; public static async create<Payload>(data: Payload) { return super.insert<Payload & DefaultUserData>({ ...data, role: data.role || Role.Blogger, }); } public static findByEmail(email: string): Promise<User | null> { return this.findOneBy< { email: string; }, User >({ email }); } public static findByUsername(username: string): Promise<User | null> { return this.findOneBy< { username: string; }, User >({ username }); } }

用户模型


在用户模型中,如果负载未提供,我将默认设置role 。现在我们已经准备好了模型,我们可以继续在控制器和中间件中使用它们。

认证中间件

Node.js 应用程序中的身份验证中间件负责验证传入请求,确保它们来自有效且授权的用户。


它通常拦截传入请求,提取身份验证令牌或凭据,并根据预定义的身份验证机制(例如本例中的 JWT(JSON Web 令牌))验证其有效性。


如果身份验证过程成功,中间件将允许请求继续进行到请求-响应周期中的下一个处理程序。然而,如果身份验证失败,它会以适当的 HTTP 状态代码(例如 401 Unauthorized)进行响应,并可选择提供错误消息。


创建文件夹src/middlewares ,并在其中添加文件authMiddleware.ts ,其中包含以下代码:

 import { jwt } from 'src/utils/jwt'; import { Redis } from 'src/redis'; import type { Request, Response, NextFunction } from 'express'; import type { UserSession } from 'src/@types'; export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; const JWT_SECRET = process.env.JWT_SECRET; if (!token) return res.sendStatus(401); if (!JWT_SECRET) { console.error('JWT_SECRET Not Found'); return res.sendStatus(500); } if (!token) return res.status(401).json({ error: 'Token not provided' }); try { const userSession = await jwt.verify<UserSession>(token); if (!userSession) { return res.sendStatus(401); } const storedToken = await Redis.getSession(userSession.id); if (!storedToken || storedToken !== token) { return res.sendStatus(401); } req.user = userSession; next(); } catch (error) { console.error('JWT_ERROR', error); return res.sendStatus(401); } }

Auth中间件文件


auth 中间件从请求标头中提取 JWT 令牌,使用 JWT 库验证其有效性,并检查该令牌是否与 Redis 中存储的令牌匹配。


如果令牌有效并且与存储的令牌匹配,则中间件在请求对象 ( req.user ) 上设置经过身份验证的用户会话,并调用next()函数将控制权传递给下一个中间件或路由处理程序。否则,它会响应 401 状态代码,指示身份验证失败。

JSON 网络令牌

让我们回顾一下 jwt 的 util。使用以下代码在src/utils/jwt.ts文件中创建:

 require('dotenv').config(); import jsonwebtoken from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET as string; export const jwt = { verify: <Result>(token: string): Promise<Result> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => { if (error) { reject(error); } else { resolve(decoded as Result); } }); }); }, sign: (payload: string | object | Buffer): Promise<string> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { try { resolve(jsonwebtoken.sign(payload, JWT_SECRET)); } catch (error) { reject(error); } }); }, };

JWT 实用程序文件


该实用程序在 Node.js 应用程序中处理 JSON Web 令牌方面发挥着关键作用。 jwt对象利用jsonwebtoken库导出用于签名和验证 JWT 的函数。这些函数有助于 JWT 的创建和验证,这对于在应用程序中实现身份验证机制至关重要。


实用程序封装了处理 JWT 的功能,确保 Node.js 应用程序内的安全身份验证机制,同时遵循环境变量管理的最佳实践。

雷迪斯

用作数据库、缓存和消息代理。常用于各种用例,包括缓存、会话管理、实时分析、消息队列、排行榜等。


从 Redis 检查令牌可作为 JWT 令牌的附加安全和验证层。让我们深入了解一下设置。为此,使用以下代码在文件src/redis/index.ts中创建:

 require('dotenv').config({ path: '../../.env', }); import process from 'process'; import * as redis from 'redis'; const client = redis.createClient({ url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, }); client.on('error', error => console.error('Redis Client Error', error)); const connect = async () => { try { await client.connect(); console.log('Connected to Redis'); } catch (err) { console.error(`Could not connect to Redis: ${err}`); process.exit(1); } }; class Redis { public static setSession(userId: number, token: string) { if (!userId) throw new Error('userId is required'); if (!token) throw new Error('token is required'); try { return client.set(`session:${userId}`, token); } catch (error) { console.error(error); } } public static getSession(userId: number) { if (!userId) throw new Error('userId is required'); return client.get(`session:${userId}`); } public static deleteSession(userId: string) { if (!userId) throw new Error('userId is required'); try { return client.del(`session:${userId}`); } catch (error) { console.error(error); } } } export { client, connect, Redis };

Redis 会话存储


通过 Redis,我们将存储和管理用户会话令牌。在 auth 中间件中,验证 JWT 令牌的真实性后,中间件会检查该令牌是否存在以及是否与 Redis 中存储的相应用户会话的令牌相匹配。这有助于确保只有有效且授权的用户才能访问受保护的路由。


Redis 用作键值存储来维护用户会话令牌。当用户登录或进行身份验证时,他们的会话令牌将存储在 Redis 中。这允许在后续身份验证检查期间高效、快速地检索会话令牌。


身份验证中间件中使用 Redis 进行高效的会话管理,而 Redis 相关文件则处理 Redis 服务器的配置和连接,并提供在应用程序其他部分与 Redis 交互的功能。


此设置可确保安全可靠的身份验证机制,并在 Redis 中存储和管理用户会话令牌。


最后一部分是我们必须在入口点连接到 Redis:

 // all imports import * as Redis from 'src/redis'; const app = express(); const PORT = process.env.PORT || 9999; // middlewares (async () => { try { await Redis.connect(); app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();

连接到 Redis


完成身份验证准备后,我们现在可以将重点转移到控制器上。

控制器

路由中的控制器通过分离关注点和提高代码可维护性来帮助组织应用程序的逻辑。我们已经创建了用于健康检查的控制器。接下来,我们将继续创建用于处理用户操作的控制器。


我们要采用的第一个控制器是sessionController.ts ,它必须位于src/controllers中,并包含以下代码:

 import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const user = await UserModel.findOneById<User>(req.user.id); if (user) { return res.status(200).json(user); } else { return res.sendStatus(401); } } catch (error) { return res.sendStatus(500); } };

会话控制器


该控制器用于处理与会话相关的端点,可能负责检索有关当前经过身份验证的用户的信息。我们需要这个控制器的原因如下:


用户会话信息:此控制器允许应用程序检索有关用户会话的信息,例如用户配置文件或其他相关数据。此信息可用于定制用户体验或根据用户的个人资料提供个性化内容。


身份验证和授权:通过检查req.user是否存在,控制器确保只有经过身份验证的用户才能访问端点。这有助于强制执行身份验证和授权规则,确保敏感的用户数据只能由授权用户访问。


用户配置文件检索:控制器查询数据库(使用UserModel )以根据会话 ID 检索用户的信息。这允许应用程序动态获取特定于用户的数据,为每个用户提供量身定制的体验。这部分肯定可以通过Redis缓存来改进:

 import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const cachedProfile = await Redis.getSession(req.user.id); if (cachedProfile) { return res.status(200).json(JSON.parse(cachedProfile)); } else { const user = await UserModel.findOneById<User>(req.user.id); if (user) { await Redis.setSession(req.user.id, JSON.stringify(user), CACHE_EXPIRATION); return res.status(200).json(user); } else { return res.sendStatus(401); } } } catch (error) { console.error('Error retrieving user profile:', error); return res.sendStatus(500); } };

带有 Redis 设置会话的会话控制器文件


我们定义一个常量CACHE_EXPIRATION来指定缓存过期时间(以秒为单位)。在此示例中,它设置为 3600 秒(1 小时)。缓存的数据会定期刷新,防止向用户提供过时的数据并维护缓存内的数据完整性。


在继续创建在我们的应用程序中管理新用户注册过程的signUpController之前,让我们回顾一下架构:

注册流程架构

在我们的例子中,当尝试使用数据库中现有的电子邮件进行注册时,我们通过不明确透露用户是否存在来优先考虑用户隐私。相反,我们会用一条通用消息通知客户,说明Invalid email or password


这种方法鼓励客户端提交有效的凭据,而不会泄露有关现有用户的不必要的信息。


现在让我们创建src/controllers/auth/signUpController.ts ,并添加以下代码:

 import bcrypt from 'bcrypt'; import { jwt } from 'src/utils/jwt'; import { Request, Response } from 'express'; import { validate } from 'src/helpers/validation/validate'; import { userSchema } from 'src/helpers/validation/schemas/userSchema'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; import { getRandomString } from 'src/utils/getRandomString'; type Payload = Omit<User, 'id' | 'created_at' | 'updated_at' | 'role'>; export async function signUpController(req: Request, res: Response) { const { email, password }: Payload = req.body; const validation = validate<Payload>(req.body, userSchema); if (!validation.isValid) { return res.status(400).send(`Invalid ${validation.invalidKey}`); } try { const user = await UserModel.findOneBy({ email }); if (user) { return res.status(400).json({ message: 'Invalid email or password' }); } const hashedPassword = (await bcrypt.hash(password, 10)) as string; const username = `${email.split('@')[0]}${getRandomString(5)}`; const createdUser = await UserModel.create<Payload>({ email, password: hashedPassword, username, }); const token = await jwt.sign({ id: createdUser.id, }); await Redis.setSession(createdUser.id, token); res.status(200).json({ token, }); } catch (error) { return res.sendStatus(500); } }

注册控制器


控制器通常从注册表单接收包含用户电子邮件和密码的请求。它根据预定义的userSchema验证传入数据,以确保其满足所需的格式。


如果验证成功通过,表明不存在现有用户和有效字段,控制器将继续使用bcrypt.hash对密码进行哈希处理,生成username ,并使用UserModel.create创建用户。


最后,它使用jwt生成token ,在Redis中设置session数据,并将token发送回用户。


现在,让我们重点关注登录控制器的创建。创建文件src/controllers/auth/loginController.ts


 require('dotenv').config({ path: '../../.env', }); import bcrypt from 'bcrypt'; import { Request, Response } from 'express'; import { jwt } from 'src/utils/jwt'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export async function loginController(req: Request, res: Response) { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: 'Invalid email or password' }); } try { const user = await UserModel.findByEmail(email); if (user) { const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { return res.status(400).json({ message: 'Invalid email or password' }); } const token: string = await jwt.sign({ id: user.id, }); await Redis.setSession(user.id, token); res.status(200).json({ token }); } else { return res.status(400).json({ message: 'Invalid email or password' }); } } catch (error) { console.error(error); return res.sendStatus(500); } }

登录控制器


本质上,我们首先验证提供的字段,然后检查用户是否存在。如果未找到用户,我们会响应 400 状态代码以及消息Invalid email or password ,类似于signupController中的行为。


如果用户存在,我们将使用bcrypt.compare继续将提供的密码与存储在数据库中的哈希密码进行比较。


如果密码不匹配,我们会回复熟悉的消息“电子邮件或密码无效”。最后,身份验证成功后,我们生成一个令牌,在 Redis 中设置会话,并将令牌发送回客户端。


让我们回顾一下受保护的控制器,它依赖于从中间件获取的 user_id 是否存在。我们始终依赖此 user_id 在这些控制器中进行操作。如果请求缺少authorization标头,我们必须使用401状态代码进行响应。


 const authHeader = req.headers['authorization'];


使用以下代码创建文件src/controllers/user/logoutController.ts


 import type { Request, Response } from 'express'; import { Redis } from 'src/redis'; export async function logoutController(req: Request, res: Response) { try { await Redis.deleteSession(req.user.id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } }

注销控制器


logoutController负责从系统中注销用户。收到请求后,它与 Redis 客户端交互以删除与user.id关联的会话。如果操作成功,则返回200状态码以指示注销成功。


但是,如果在此过程中发生错误,它会以500状态代码进行响应,以表示内部服务器错误。


接下来,我们来解决用户数据的删除问题。


创建src/controllers/user/deleteUserController.ts ,并添加以下代码:

 import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export const deleteUserController = async (req: Request, res: Response) => { const user_id = req.user.id; try { await Redis.deleteSession(user_id); await UserModel.delete(user_id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } };

删除用户控制器


当收到请求时,它从请求对象中提取用户 ID,通常从身份验证中间件获取。


随后,它继续使用 Redis 客户端从 Redis 中删除与此user_id关联的会话。然后,它调用UserModeldelete方法从数据库中删除用户的数据。


成功删除会话和用户数据后,它会响应200状态代码以指示删除成功。如果删除过程中发生错误,它会响应500状态代码以表示内部服务器错误。


要更新系统中的用户数据,请创建src/controllers/user/updateUserController.ts ,并将以下代码添加到文件中:

 import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { filterObject } from 'src/utils/filterObject'; type Payload = { first_name?: string; last_name?: string; username?: string; }; export const updateUserController = async (req: Request, res: Response) => { const { first_name, last_name, username } = req.body; const payload: Payload = filterObject({ first_name, last_name, username, }); try { const existingUserName = await UserModel.findByUsername(username); if (existingUserName) { return res.status(400).json({ error: 'Invalid username', }); } const updatedUser = await UserModel.updateOneById<typeof payload>(req.user.id, payload); res.status(200).json(updatedUser); } catch (error) { res.sendStatus(500); } };

更新用户控制器


收到请求后,它从请求正文中提取字段first_namelast_nameusername 。接下来,它使用filterObject实用函数过滤这些字段,以确保有效负载中仅包含有效字段。


随后,它检查提供的username是否已存在于数据库中。如果是,控制器会响应400状态代码和一条指示username无效的错误消息。如果username是唯一的,控制器将继续使用UserModelupdateOneById方法更新数据库中的用户数据。


成功更新后,它会响应200状态代码和更新的用户数据。如果更新过程中出现任何错误,控制器会响应500状态代码以表示内部服务器错误。


最后一个是更新密码,与更新用户数据的想法几乎相同,但对新密码进行哈希处理。从列表src/controllers/user/updatePasswordController.ts创建最后一个控制器,并添加代码:


 import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import bcrypt from 'bcrypt'; export const updatePasswordController = async (req: Request, res: Response) => { try { const { password } = req.body; if (!password) return res.sendStatus(400); const hashedPassword = (await bcrypt.hash(password, 10)) as string; const user = await UserModel.updateOneById(req.user.id, { password: hashedPassword }); return res.status(200).json({ id: user.id }); } catch (error) { return res.sendStatus(500); } };

更新密码控制器


收到请求后,它从请求正文中提取新密码。然后,它检查请求正文中是否提供了密码。如果不是,它会响应400状态代码,表明请求错误。接下来,它使用盐因子为 10 的bcrypt库对新密码进行哈希处理。


然后,使用UserModelupdateOneById方法将哈希密码安全地存储在数据库中,并将其与user.id关联起来。成功更新密码后,控制器会响应200状态代码和包含用户 ID 的 JSON 对象。


如果密码更新过程中出现任何错误,控制器会像其他控制器一样以500状态代码进行响应,以指示内部服务器错误。


确保从以下位置检查和设置验证助手和实用程序GitHub 存储库。配置完成后,您应该准备好测试端点。


让我们检查一下注册端点:


授权注册点


显然,我们已经获得了一个令牌,它将在标头中用于检索会话。


响应会话的结果


我们将标头中的授权令牌发送到服务器,作为响应,服务器向我们提供从数据库检索的用户数据。


请随意探索和试验安全功能和 Redis 缓存。基础模型就位后,您可以深入研究其他功能,例如为忘记密码的用户恢复帐户。不过,这个主题将保留到以后的文章中。

结论

以可扩展的方式管理路由和用户身份验证流程可能具有挑战性。虽然我们已经实施了中间件来保护路由,但还有其他策略可用于增强服务的性能和可靠性。


通过提供更清晰的错误消息可以进一步增强用户体验,因为错误处理仍然是需要更全面覆盖的一个重要方面。不过,我们已经成功实现了主要身份验证流程,使用户能够注册、访问其帐户、检索会话数据、更新用户信息和删除帐户。


我希望您觉得这次旅程富有洞察力,并获得了有关用户身份验证的宝贵知识。

资源

GitHub 存储库
Knex.js
表达
创建节点应用程序
邮差


也发布在这里