Trong quá trình tìm kiếm giải pháp xác thực nhanh chóng, trực quan và hợp lý cho các ứng dụng Node.js của mình, tôi đã gặp phải các tình huống yêu cầu triển khai nhanh chóng mà không ảnh hưởng đến chức năng.
Từ đăng ký và đăng nhập của người dùng đến quản lý mật khẩu bị quên, cập nhật dữ liệu người dùng và thậm chí xóa tài khoản, tôi đã tìm kiếm một giải pháp toàn diện giúp điều hướng liền mạch thông qua các tương tác thiết yếu này của người dùng.
Vì vậy, bài viết của tôi nhằm mục đích trình bày chính xác điều đó - một cách tiếp cận gắn kết tích hợp các phương pháp rõ ràng để triển khai xác thực và bộ nhớ đệm, đảm bảo luồng người dùng mạnh mẽ và hiệu quả.
Ở đây, chúng ta sẽ bỏ qua các quy trình cài đặt cơ bản và tạo mô hình, trực tiếp tập trung vào sự phức tạp của xác thực và luồng người dùng. Chúng tôi sẽ bao gồm tất cả các liên kết cần thiết để lấy tệp cấu hình trong suốt bài viết, đảm bảo quyền truy cập liền mạch vào các tài nguyên cần thiết để thiết lập.
Để triển khai việc này, chúng tôi sẽ tận dụng Node.js phiên bản 20.11.1 cùng với Knex, Express và Redis. Ngoài ra, chúng tôi sẽ sử dụng PostgreSQL làm cơ sở dữ liệu, cơ sở dữ liệu này sẽ được đóng gói và điều phối bằng Docker để quản lý liền mạch.
Tên ứng dụng của chúng ta sẽ là user-flow-boilerplate
. Hãy tạo thư mục đó và chạy npm init -y
bên trong để tạo package.json
cơ bản
{ "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" }
Gói ban đầu.json
Bước tiếp theo là thêm các phụ thuộc cần thiết:
phụ thuộc : npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator
devDependency :
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
và thêm các tập lệnh sẽ xây dựng và chạy ứng dụng của chúng tôi:
"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" },
Để đảm bảo ứng dụng của chúng ta khởi chạy suôn sẻ, điều cần thiết là phải tạo một thư mục src
và đặt tệp điểm nhập ban đầu của chúng ta, index.ts
, trong đó.
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); } })();
Tệp điểm vào
Để phát triển, chúng ta cần có các cài đặt cho typscript
, lint
, jest
, bable
, prettier
, nodemon
. Tất cả những tệp đó tôi đã mô tả trong bài viết sau: Tạo máy chủ Node.js bằng Postgres và Knex trên Express .
Sau khi định cấu hình tất cả cài đặt và tạo điểm vào, việc thực thi npm run dev
sẽ khởi động máy chủ và bạn sẽ thấy đầu ra tương tự như sau:
./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
Tiếp theo, điều hướng đếnGET
mới, nhấn cmd + E
(trên máy Mac, nhưng các phím phụ thuộc vào hệ điều hành của bạn) và đặt tên là health
.
Thêm nhập cho URL: {{BASE_URI}}/health
. Đối với BASE_URI
, hãy thêm một biến mới mà bạn sẽ sử dụng trên toàn bộ bộ sưu tập: http://localhost:9999/api/v1
Sau đó, chỉ cần nhấp vào nút 'Gửi' và bạn sẽ quan sát nội dung phản hồi:
{ "message": "OK" }
Trước khi tiếp tục, điều quan trọng là phải thiết lập và chạy cơ sở dữ liệu của chúng tôi. Chúng tôi sẽ thực hiện điều này bằng cách khởi chạy nó với docker-compose
. Để truy cập và quản lý cơ sở dữ liệu, bạn có thể sử dụng nhiều nền tảng phát triển khác nhau như
Cá nhân tôi thích sử dụng hơn
Chúng tôi cần tệp .env
với các khóa, mật khẩu và tên kiểm tra cần thiết:
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 để kết nối tới cơ sở dữ liệu, Redis và kiểm tra các giá trị cho hạt giống
Đừng lo, tôi đã tạo ngẫu nhiên JWT_SECRET
để minh họa nó một cách chân thực hơn. Vì vậy, hãy tạo một tệp docker-compose.yml
ở thư mục gốc của dự án:
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
tập tin soạn thảo docker với các dịch vụ
Chúng tôi sẽ triển khai hai dịch vụ trong Docker để kết nối nhanh chóng. Tôi đã hợp lý hóa quy trình này để tạo điều kiện truy cập nhanh vào cơ sở dữ liệu hoặc Redis, cho phép chúng tôi truy xuất dữ liệu một cách hiệu quả. Vì vậy, hãy chạy các dịch vụ đó docker-compose up
và chúng ta phải có thể thấy đầu ra sau docker ps
đầu ra sau:
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
Bây giờ, chúng ta cần tạo tệp src/@types/index.ts
nơi chúng ta lưu trữ các loại của mình cho ứng dụng:
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; }
Các loại dịch vụ
Tại thời điểm này, bạn cần có knexfile.ts
trong thư mục gốc của dự án và cơ sở dữ liệu để kết nối, di chuyển và hạt giống.
Tôi đã để lại lời giải thích khá chi tiết trong bài viết Tạo máy chủ Node.js bằng Postgres và Knex trên Express về cách di chuyển và đưa người dùng vào cơ sở dữ liệu nơi chúng tôi đang sử dụng các biến env đó.
Tôi muốn kiểm tra cụ thể việc di chuyển để đảm bảo rằng chúng tôi ở trên cùng một trang. Chúng tôi đã triển khai dịch vụ của mình và chúng tôi phải có khả năng kiểm tra kết nối với cơ sở dữ liệu.
docker exec -it postgres psql -U username_123 user_flow_boilerplate
Nếu kết nối tốt thì bạn sẽ ở trong bảng điều khiển psql
. Được rồi, nếu kết nối không có vấn đề gì thì chúng ta có thể di chuyển các bảng của mình đến đó. Chạy knex migrate:latest
. Sau đó, bạn nên quan sát các cột mới được thêm vào trong bảng users
trong cơ sở dữ liệu.
Hãy gieo nó với dữ liệu giả knex seed:run
và kiểm tra lại bảng.
Vì vậy, giờ đây chúng ta đã được trang bị để thao tác với cơ sở dữ liệu, cho phép chúng ta thêm, xóa hoặc cập nhật người dùng khi cần.
Cuối cùng, chúng ta có thể quên đi cài đặt và sự chuẩn bị mà tập trung cụ thể vào luồng người dùng. Để làm được điều đó, chúng ta cần tạo một bộ định tuyến. Chúng ta cần xử lý bằng bộ định tuyến đó các hoạt động sau: login
, logout
, signup
, delete_user
, update_user
.
Để làm điều đó, trên src/routes/index.ts
, hãy thêm đoạn mã sau:
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' }); });
Tệp tuyến đường
Như bạn có thể thấy, lúc đầu, chúng tôi đã thêm tuyến đường /health
mà chúng tôi đã kiểm tra. Vì vậy, hãy cập nhật điểm vào để áp dụng các tuyến đường đó ở đó. Đầu tiên, xóa get
trước đó.
-> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));
và thêm vào đầu tập tin:
import { router } from 'src/routes'; // ... app.use(cors()); app.use('/api/v1', router);
và tạo bộ điều khiển đầu tiên để kiểm tra health
src/controllers/healthController.ts
bằng mã:
import { Request, Response } from 'express'; export const healthController = (_: Request, res: Response) => res.status(200).send('ok');
Kiểm soát sức khỏe
Bây giờ, hãy quay lại bộ định tuyến và kiểm tra xem chúng ta có gì để thêm vào các tuyến đường. Chúng ta cần thêm hai tệp nữa: authRouter.ts
và userRouter.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);
Bộ định tuyến xác thực
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);
Bộ định tuyến người dùng
Tôi đã chia logic này để dễ đọc và có trách nhiệm duy trì chức năng riêng biệt. Tất cả các tuyến đường đó đều cần bộ điều khiển để chúng tôi xử lý logic.
Các tuyến xác thực và tình trạng không cần phần mềm trung gian xác thực, vì vậy các tuyến đó không được bảo vệ, nhưng nếu không có kết quả trùng khớp, chúng tôi sẽ nhận được trạng thái 404.
router.get('/health', healthController); router.use('/auth', authRouter);
Bây giờ, khi chúng ta đã giải quyết xong tất cả các tuyến đường, chúng ta phải thiết lập mô hình người dùng.
Tôi sẽ sử dụng mô hình cơ sở cho mô hình người dùng, từ đó tôi sẽ sử dụng lại các phương thức CRUD. Mặc dù trước đây tôi đã trình bày việc tạo mô hình trong một bài khácsrc/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(); } }
Mô hình cơ sở
Với mô hình cơ sở, chúng ta phải có khả năng tạo UserModel.ts
trong cùng một thư mục:
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 }); } }
Mô hình người dùng
Trong mô hình người dùng, tôi chỉ đặt role
theo mặc định nếu không được cung cấp từ tải trọng. Và bây giờ chúng ta đã có sẵn các mô hình của mình, chúng ta có thể tiến hành sử dụng chúng trong bộ điều khiển và phần mềm trung gian của mình.
Phần mềm trung gian xác thực trong ứng dụng Node.js chịu trách nhiệm xác thực các yêu cầu đến, đảm bảo rằng chúng đến từ người dùng hợp lệ và được ủy quyền.
Nó thường chặn các yêu cầu đến, trích xuất mã thông báo hoặc thông tin xác thực và xác minh tính hợp lệ của chúng dựa trên cơ chế xác thực được xác định trước, chẳng hạn như JWT (Mã thông báo web JSON) trong trường hợp này.
Nếu quá trình xác thực thành công, phần mềm trung gian sẽ cho phép yêu cầu chuyển sang trình xử lý tiếp theo trong chu trình phản hồi yêu cầu. Tuy nhiên, nếu xác thực không thành công, nó sẽ phản hồi bằng mã trạng thái HTTP thích hợp (ví dụ: 401 trái phép) và tùy chọn cung cấp thông báo lỗi.
Tạo thư mục src/middlewares
và thêm vào đó một tệp authMiddleware.ts
với mã sau:
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); } }
Xác thực tệp phần mềm trung gian
Phần mềm trung gian xác thực trích xuất mã thông báo JWT từ tiêu đề yêu cầu, xác minh tính hợp lệ của nó bằng thư viện JWT và kiểm tra xem mã thông báo có khớp với mã được lưu trữ trong Redis hay không.
Nếu mã thông báo hợp lệ và khớp với mã thông báo được lưu trữ, phần mềm trung gian sẽ đặt phiên người dùng được xác thực trên đối tượng yêu cầu ( req.user
) và gọi hàm next()
để chuyển quyền điều khiển cho phần mềm trung gian hoặc trình xử lý tuyến tiếp theo. Nếu không, nó sẽ phản hồi bằng mã trạng thái 401 cho biết xác thực không thành công.
Hãy cùng xem lại công dụng của jwt. Tạo trong tệp src/utils/jwt.ts
với mã sau:
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); } }); }, };
Tệp tiện ích JWT
Tiện ích này đóng vai trò quan trọng trong việc xử lý Mã thông báo Web JSON trong ứng dụng Node.js. Đối tượng jwt
xuất các chức năng cho cả việc ký và xác minh JWT, tận dụng thư viện jsonwebtoken
. Các chức năng này tạo điều kiện thuận lợi cho việc tạo và xác thực JWT, cần thiết để triển khai các cơ chế xác thực trong ứng dụng.
Tiện ích gói gọn chức năng xử lý JWT, đảm bảo cơ chế xác thực an toàn trong ứng dụng Node.js đồng thời tuân thủ các phương pháp hay nhất để quản lý biến môi trường.
Được sử dụng làm cơ sở dữ liệu, bộ nhớ đệm và môi giới tin nhắn. Thường được sử dụng trong nhiều trường hợp sử dụng, bao gồm bộ nhớ đệm, quản lý phiên, phân tích thời gian thực, hàng đợi nhắn tin, bảng xếp hạng, v.v.
Việc kiểm tra mã thông báo từ Redis đóng vai trò như một lớp bảo mật và xác thực bổ sung cho mã thông báo JWT. Hãy đi sâu vào cài đặt. Để làm điều đó, hãy tạo trong tệp src/redis/index.ts
với đoạn mã sau:
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 };
Cửa hàng phiên Redis
Bằng Redis, chúng tôi sẽ lưu trữ và quản lý mã thông báo phiên của người dùng. Trong phần mềm trung gian xác thực, sau khi xác minh tính xác thực của mã thông báo JWT, phần mềm trung gian sẽ kiểm tra xem mã thông báo có tồn tại hay không và khớp với mã được lưu trữ trong Redis cho phiên người dùng tương ứng. Điều này giúp đảm bảo rằng chỉ những người dùng hợp lệ và được ủy quyền mới có thể truy cập các tuyến đường được bảo vệ.
Redis được sử dụng làm kho lưu trữ khóa-giá trị để duy trì mã thông báo phiên của người dùng. Khi người dùng đăng nhập hoặc xác thực, mã thông báo phiên của họ sẽ được lưu trữ trong Redis. Điều này cho phép truy xuất mã thông báo phiên một cách hiệu quả và nhanh chóng trong các lần kiểm tra xác thực tiếp theo.
Redis được sử dụng trong phần mềm trung gian xác thực để quản lý phiên hiệu quả, trong khi tệp liên quan đến Redis xử lý cấu hình và kết nối với máy chủ Redis, đồng thời cung cấp các chức năng tương tác với Redis trong các phần khác của ứng dụng.
Thiết lập này đảm bảo cơ chế xác thực an toàn và đáng tin cậy, với mã thông báo phiên người dùng được lưu trữ và quản lý trong Redis.
Phần cuối cùng là chúng ta phải kết nối với Redis tại điểm vào:
// 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); } })();
Kết nối với Redis
Sau khi hoàn tất việc chuẩn bị xác thực, bây giờ chúng ta có thể chuyển trọng tâm sang bộ điều khiển.
Bộ điều khiển trong các tuyến giúp tổ chức logic của ứng dụng bằng cách tách biệt các mối quan tâm và thúc đẩy khả năng bảo trì mã. Chúng tôi đã tạo bộ điều khiển để kiểm tra tình trạng. Tiếp theo, chúng ta sẽ tiến hành tạo bộ điều khiển để xử lý các thao tác với người dùng.
Bộ điều khiển đầu tiên mà chúng ta sẽ sử dụng là sessionController.ts
phải nằm trong src/controllers
với mã sau:
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); } };
Bộ điều khiển phiên
Bộ điều khiển này phục vụ mục đích xử lý điểm cuối liên quan đến phiên, có khả năng chịu trách nhiệm truy xuất thông tin về người dùng hiện được xác thực. Chúng tôi cần bộ điều khiển này vì những lý do sau:
Thông tin phiên người dùng: Bộ điều khiển này cho phép ứng dụng truy xuất thông tin về phiên của người dùng, chẳng hạn như hồ sơ người dùng của họ hoặc dữ liệu liên quan khác. Thông tin này có thể hữu ích cho việc tùy chỉnh trải nghiệm người dùng hoặc cung cấp nội dung được cá nhân hóa dựa trên hồ sơ của người dùng.
Xác thực và ủy quyền: Bằng cách kiểm tra xem req.user
có tồn tại hay không, bộ điều khiển đảm bảo rằng chỉ những người dùng được xác thực mới có thể truy cập điểm cuối. Điều này giúp thực thi các quy tắc xác thực và ủy quyền, đảm bảo rằng chỉ những người dùng được ủy quyền mới có thể truy cập được dữ liệu nhạy cảm của người dùng.
Truy xuất hồ sơ người dùng: Bộ điều khiển truy vấn cơ sở dữ liệu (sử dụng UserModel
) để truy xuất thông tin của người dùng dựa trên ID phiên của họ. Điều này cho phép ứng dụng tìm nạp dữ liệu cụ thể của người dùng một cách linh hoạt, cung cấp trải nghiệm phù hợp cho từng người dùng. Phần này chắc chắn có thể được cải thiện bằng bộ đệm 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); } };
Tệp Trình điều khiển phiên với phiên được đặt Redis
Chúng tôi xác định hằng số CACHE_EXPIRATION
để chỉ định thời gian hết hạn bộ đệm tính bằng giây. Trong ví dụ này, nó được đặt thành 3600 giây (1 giờ). Dữ liệu được lưu trong bộ nhớ đệm được làm mới định kỳ, ngăn không cho dữ liệu cũ được cung cấp cho người dùng và duy trì tính toàn vẹn của dữ liệu trong bộ nhớ đệm.
Trước khi tiếp tục tạo signUpController
, quản lý quá trình đăng ký cho người dùng mới trong ứng dụng của chúng ta, hãy xem lại lược đồ:
Trong trường hợp của chúng tôi, khi cố gắng đăng ký bằng email hiện có trong cơ sở dữ liệu, chúng tôi ưu tiên quyền riêng tư của người dùng bằng cách không tiết lộ rõ ràng liệu người dùng đó có tồn tại hay không. Thay vào đó, chúng tôi thông báo cho khách hàng bằng một thông báo chung nêu rõ Invalid email or password
.
Cách tiếp cận này khuyến khích khách hàng gửi thông tin xác thực hợp lệ mà không tiết lộ thông tin không cần thiết về người dùng hiện tại.
Bây giờ hãy tạo src/controllers/auth/signUpController.ts
và thêm mã sau:
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); } }
Đăng ký điều khiển
Bộ điều khiển nhận được yêu cầu chứa email và mật khẩu của người dùng, thường là từ biểu mẫu đăng ký. Nó xác thực dữ liệu đến dựa trên userSchema
được xác định trước để đảm bảo dữ liệu đáp ứng định dạng được yêu cầu.
Nếu quá trình xác thực thành công, cho biết không có người dùng hiện tại và các trường hợp lệ, bộ điều khiển sẽ tiến hành băm mật khẩu bằng cách sử dụng bcrypt.hash
, tạo username
và tạo người dùng bằng cách sử dụng UserModel.create
.
Cuối cùng, nó tạo token
bằng cách sử dụng jwt
, đặt dữ liệu session
trong Redis
và gửi lại token
cho người dùng.
Bây giờ, hãy tập trung vào việc tạo bộ điều khiển đăng nhập. Tạo tệp 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); } }
Bộ điều khiển đăng nhập
Về cơ bản, chúng tôi bắt đầu bằng cách xác thực các trường được cung cấp và sau đó kiểm tra sự tồn tại của người dùng. Nếu không tìm thấy người dùng nào, chúng tôi sẽ phản hồi bằng mã trạng thái 400 cùng với thông báo Invalid email or password
, tương tự như hành vi trong signupController
.
Nếu người dùng tồn tại, chúng tôi tiến hành so sánh mật khẩu được cung cấp với mật khẩu băm được lưu trữ trong cơ sở dữ liệu bằng cách sử dụng bcrypt.compare
.
Nếu mật khẩu không khớp, chúng tôi sẽ trả lời bằng thông báo quen thuộc 'Email hoặc mật khẩu không hợp lệ'. Cuối cùng, sau khi xác thực thành công, chúng tôi tạo mã thông báo, đặt phiên trong Redis và gửi lại mã thông báo cho khách hàng.
Hãy xem lại bộ điều khiển được bảo vệ của chúng tôi, bộ điều khiển này phụ thuộc vào sự hiện diện của user_id thu được từ phần mềm trung gian. Chúng tôi luôn dựa vào user_id này để thực hiện các hoạt động trong các bộ điều khiển này. Trong trường hợp yêu cầu thiếu tiêu đề authorization
, chúng tôi phải phản hồi bằng mã trạng thái 401
.
const authHeader = req.headers['authorization'];
Tạo tệp src/controllers/user/logoutController.ts
với đoạn mã sau:
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); } }
Bộ điều khiển đăng xuất
logoutController
này chịu trách nhiệm đăng xuất người dùng khỏi hệ thống. Khi nhận được yêu cầu, nó sẽ tương tác với ứng dụng khách Redis để xóa phiên được liên kết với user.id
Nếu thao tác thành công, nó sẽ phản hồi bằng mã trạng thái 200
để cho biết đăng xuất thành công.
Tuy nhiên, nếu xảy ra lỗi trong quá trình, nó sẽ phản hồi bằng mã trạng thái 500
để báo hiệu lỗi máy chủ nội bộ.
Tiếp theo, hãy giải quyết việc xóa dữ liệu người dùng.
Tạo src/controllers/user/deleteUserController.ts
và thêm mã này:
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); } };
Xóa bộ điều khiển người dùng
Khi nhận được yêu cầu, nó sẽ trích xuất ID người dùng từ đối tượng yêu cầu, thường được lấy từ phần mềm trung gian xác thực.
Sau đó, nó tiến hành xóa phiên được liên kết với user_id
này khỏi Redis bằng ứng dụng khách Redis. Sau đó, nó gọi phương thức delete
của UserModel
để xóa dữ liệu của người dùng khỏi cơ sở dữ liệu.
Sau khi xóa thành công cả phiên và dữ liệu người dùng, nó sẽ phản hồi bằng mã trạng thái 200
để cho biết việc xóa thành công. Trong trường hợp xảy ra lỗi trong quá trình xóa, nó sẽ phản hồi bằng mã trạng thái 500
để biểu thị lỗi máy chủ nội bộ.
Để cập nhật dữ liệu người dùng trong hệ thống, hãy tạo src/controllers/user/updateUserController.ts
và thêm mã sau vào tệp:
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); } };
Cập nhật bộ điều khiển người dùng
Khi nhận được yêu cầu, nó sẽ trích xuất các trường first_name
, last_name
và username
từ nội dung yêu cầu. Tiếp theo, nó lọc các trường này bằng hàm tiện ích filterObject
để đảm bảo rằng chỉ những trường hợp lệ mới được đưa vào tải trọng.
Sau đó, nó sẽ kiểm tra xem username
được cung cấp đã tồn tại trong cơ sở dữ liệu hay chưa. Nếu đúng như vậy, bộ điều khiển sẽ phản hồi bằng mã trạng thái 400
và thông báo lỗi cho biết username
không hợp lệ. Nếu username
là duy nhất, bộ điều khiển sẽ tiến hành cập nhật dữ liệu người dùng trong cơ sở dữ liệu bằng phương thức updateOneById
của UserModel
.
Sau khi cập nhật thành công, nó sẽ phản hồi bằng mã trạng thái 200
và dữ liệu người dùng được cập nhật. Trong trường hợp có bất kỳ lỗi nào trong quá trình cập nhật, bộ điều khiển sẽ phản hồi bằng mã trạng thái 500
để biểu thị lỗi máy chủ nội bộ.
Bước cuối cùng sẽ là cập nhật mật khẩu, ý tưởng gần giống như cập nhật dữ liệu người dùng nhưng có băm mật khẩu mới. Tạo bộ điều khiển cuối cùng từ danh sách của chúng tôi src/controllers/user/updatePasswordController.ts
và thêm mã:
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); } };
Cập nhật bộ điều khiển mật khẩu
Khi nhận được yêu cầu, nó sẽ trích xuất mật khẩu mới từ nội dung yêu cầu. Sau đó nó sẽ kiểm tra xem mật khẩu có được cung cấp trong phần thân yêu cầu hay không. Nếu không, nó sẽ phản hồi bằng mã trạng thái 400
, cho biết yêu cầu không hợp lệ. Tiếp theo, nó băm mật khẩu mới bằng thư viện bcrypt
với hệ số muối là 10.
Sau đó, mật khẩu băm được lưu trữ an toàn trong cơ sở dữ liệu bằng phương thức updateOneById
của UserModel
, liên kết nó với user.id
Sau khi cập nhật mật khẩu thành công, bộ điều khiển sẽ phản hồi bằng mã trạng thái 200
và đối tượng JSON chứa ID người dùng.
Trong trường hợp có bất kỳ lỗi nào trong quá trình cập nhật mật khẩu, bộ điều khiển sẽ phản hồi bằng mã trạng thái 500
để cho biết lỗi máy chủ nội bộ như trong các bộ điều khiển khác.
Đảm bảo xem xét và thiết lập trình trợ giúp xác thực và các tiện ích từ
Hãy kiểm tra điểm cuối đăng ký:
Rõ ràng là chúng tôi đã nhận được mã thông báo, mã này sẽ được sử dụng trong tiêu đề để truy xuất phiên.
Chúng tôi đã gửi mã thông báo ủy quyền trong tiêu đề đến máy chủ và để đáp lại, máy chủ đã cung cấp cho chúng tôi dữ liệu người dùng được truy xuất từ cơ sở dữ liệu.
Hãy thoải mái khám phá và thử nghiệm các tính năng bảo mật và bộ nhớ đệm Redis. Với mô hình nền tảng hiện có, bạn có thể nghiên cứu sâu hơn các chức năng bổ sung, chẳng hạn như khôi phục tài khoản cho người dùng quên mật khẩu. Tuy nhiên, chủ đề này sẽ được dành cho một bài viết trong tương lai.
Việc quản lý luồng định tuyến và xác thực người dùng theo cách có thể mở rộng có thể là một thách thức. Mặc dù chúng tôi đã triển khai phần mềm trung gian để bảo vệ các tuyến đường nhưng vẫn có các chiến lược bổ sung để nâng cao hiệu suất và độ tin cậy của dịch vụ.
Trải nghiệm người dùng được nâng cao hơn nữa bằng cách cung cấp các thông báo lỗi rõ ràng hơn vì việc xử lý lỗi vẫn là một khía cạnh quan trọng đòi hỏi phạm vi bảo hiểm toàn diện hơn. Tuy nhiên, chúng tôi đã triển khai thành công luồng xác thực chính, cho phép người dùng đăng ký, truy cập tài khoản của họ, truy xuất dữ liệu phiên, cập nhật thông tin người dùng và xóa tài khoản.
Tôi hy vọng bạn thấy hành trình này thật sâu sắc và thu được kiến thức có giá trị về xác thực người dùng.
Cũng được xuất bản ở đây