paint-brush
Comment maîtriser l'authentification et le flux d'utilisateurs dans Node.js avec Knex et Redisby@antonkalik
585
585

Comment maîtriser l'authentification et le flux d'utilisateurs dans Node.js avec Knex et Redis

Anton Kalik26m2024/03/17
Read on Terminal Reader

Utilisation de Knex pour la gestion de bases de données, Redis pour une mise en cache efficace et Express pour un routage transparent. Créez et comprenez une solution d'authentification robuste pour le serveur Node js à l'aide de Knex et Redis. Utilisez Knex pour créer une base de données pour Node.js, puis Redis pour mettre en cache et Express pour acheminer les données.
featured image - Comment maîtriser l'authentification et le flux d'utilisateurs dans Node.js avec Knex et Redis
Anton Kalik HackerNoon profile picture
0-item

Créez une solution d'authentification robuste pour le serveur Node js en utilisant Knex pour la gestion de base de données, Redis pour une mise en cache efficace et Express pour un routage transparent.

Dans ma quête d'une solution d'authentification rapide, intuitive et rationalisée pour mes applications Node.js, j'ai rencontré des scénarios exigeant une mise en œuvre rapide sans compromettre les fonctionnalités.


De l'inscription et de la connexion des utilisateurs à la gestion des mots de passe oubliés, en passant par la mise à jour des données utilisateur et même la suppression de comptes, j'ai recherché une solution complète qui navigue de manière transparente à travers ces interactions utilisateur essentielles.


Ainsi, mon article vise précisément à présenter cela : une approche cohérente intégrant des méthodologies claires pour mettre en œuvre l'authentification et la mise en cache, garantissant un flux d'utilisateurs robuste et efficace.


Ici, nous contournerons les procédures d'installation fondamentales et la création de modèles, en nous concentrant directement sur les subtilités de l'authentification et du flux des utilisateurs. Nous inclurons tous les liens nécessaires pour obtenir les fichiers de configuration tout au long de l'article, garantissant ainsi un accès transparent aux ressources nécessaires à la configuration.

Outils

Pour cette implémentation, nous utiliserons la version 20.11.1 de Node.js aux côtés de Knex, Express et Redis. De plus, nous utiliserons PostgreSQL comme base de données, qui sera conteneurisée et orchestrée à l'aide de Docker pour une gestion transparente.


Le nom de notre application sera user-flow-boilerplate . Créons ce dossier et exécutons npm init -y pour générer package.json de base


 { "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 initial.json


L'étape suivante consiste à ajouter les dépendances nécessaires :


dépendances : npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator


Dépendances dev :

 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


et ajoutez des scripts qui construiront et exécuteront notre application :

 "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" },


Pour assurer le bon lancement de notre application, il est essentiel de créer un dossier src et d'y placer notre fichier de point d'entrée initial, 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); } })();

Fichier de point d'entrée


Pour le développement, nous avons besoin d'avoir des paramètres pour typscript , lint , jest , bable , prettier , nodemon . Tous ces fichiers que j'ai décrits dans l'article suivant : Création d'un serveur Node.js avec Postgres et Knex sur Express .


Après avoir configuré tous les paramètres et créé le point d'entrée, l'exécution npm run dev devrait lancer le serveur et vous devriez vous attendre à voir un résultat similaire à celui-ci :

 ./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


Ensuite, accédez à Facteur où nous établirons une collection dédiée au test de nos points de terminaison. Dans la nouvelle collection, ajoutez une nouvelle requête GET , appuyez sur cmd + E (sur Mac, mais les touches dépendent de votre système d'exploitation) et nommez-la health .


Ajoutez une entrée pour l'URL : {{BASE_URI}}/health . Pour BASE_URI , ajoutez une nouvelle variable que vous allez utiliser dans la collection : http://localhost:9999/api/v1

URL de base définie par le facteur

Ensuite, cliquez simplement sur le bouton « Envoyer » et vous devriez observer le corps de la réponse :

 { "message": "OK" }


Base de données

Avant d'aller de l'avant, il est crucial que notre base de données soit opérationnelle. Nous y parviendrons en le lançant avec docker-compose . Pour accéder et gérer la base de données, vous pouvez utiliser diverses plateformes de développement telles que pgAdmin .


Personnellement, je préfère utiliser RubyMine , qui est équipé d'un pilote permettant une connectivité transparente aux bases de données PostgreSQL pour une gestion efficace.


Nous avons besoin d'un fichier .env avec les clés, mots de passe et noms de tests nécessaires :

 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 pour la connexion à la base de données, Redis et les valeurs de test pour les graines


N'ayez crainte, j'ai généré aléatoirement le JWT_SECRET pour l'illustrer de manière plus authentique. Créons donc un fichier docker-compose.yml à la racine du projet :

 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

fichier docker-compose avec services


Nous allons lancer deux services dans Docker pour une connectivité rapide. J'ai rationalisé ce processus pour faciliter un accès rapide à la base de données ou à Redis, nous permettant ainsi de récupérer efficacement les données. Alors, exécutons ces services docker-compose up , et nous devons pouvoir voir la sortie après la sortie suivante 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


Maintenant, nous devons créer le fichier src/@types/index.ts dans lequel nous stockons nos types pour l'application :

 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; }

Types de service


À ce stade, vous devez avoir knexfile.ts à la racine du dossier de projet et de base de données pour la connexion, les migrations et les graines.


J'ai laissé une explication assez détaillée dans l'article Création d'un serveur Node.js avec Postgres et Knex sur Express sur la façon de migrer et d'amorcer les utilisateurs vers la base de données où nous utilisons ces variables d'environnement.


J'aimerais vérifier spécifiquement les migrations pour m'assurer que nous sommes sur la même longueur d'onde. Nous avons déjà lancé nos services, et nous devons pouvoir vérifier la connexion à la base de données.

 docker exec -it postgres psql -U username_123 user_flow_boilerplate


Si la connexion est bonne, vous serez alors dans la console psql . Ok, si la connexion ne pose aucun problème, alors nous devrions pouvoir y migrer nos tables. Exécutez knex migrate:latest . Ensuite, vous devriez observer les colonnes nouvellement ajoutées dans votre table users dans la base de données.

Tableau des utilisateurs après la migration

Semons-le avec de fausses données knex seed:run et vérifions à nouveau le tableau.

Résultat dans la base de données après la graine

Nous sommes donc désormais équipés pour manipuler la base de données, ce qui nous permet d'ajouter, de supprimer ou de mettre à jour des utilisateurs selon nos besoins.

Routeur

Enfin, nous pouvons oublier les paramètres et la préparation et nous concentrer spécifiquement sur le flux des utilisateurs. Pour cela, nous devons créer un routeur. Nous devons gérer par ce routeur les opérations suivantes : login , logout , signup , delete_user , update_user .


Pour cela, sur src/routes/index.ts , ajoutez le code suivant :

 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' }); });

Fichier d'itinéraires


Comme vous pouvez le voir, au début, nous avons ajouté /health route que nous avons déjà vérifié. Alors, mettons à jour le point d’entrée pour y appliquer ces routes. Tout d’abord, supprimez get précédent.

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


et ajoutez en haut du fichier :

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


et créez le premier contrôleur pour le contrôle health src/controllers/healthController.ts avec le code :

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

Contrôleur de santé


Revenons maintenant au routeur et vérifions ce que nous devons ajouter de plus aux routes. Nous devons ajouter deux fichiers supplémentaires : authRouter.ts et 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);

Routeur d'authentification


 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);

Routeur utilisateur


J'ai divisé cette logique par souci de lisibilité et par souci de responsabilité de maintenir des fonctionnalités isolées. Toutes ces routes ont besoin de contrôleurs dont nous allons gérer la logique.


Contrôleurs


Les routes d'authentification et de santé n'ont pas besoin d'un middleware d'authentification, donc ces routes ne sont pas protégées, mais s'il n'y a pas de correspondance, nous obtiendrons un statut 404.

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

Maintenant que nous avons défini tous les itinéraires, nous devons définir le modèle utilisateur.

Modèle utilisateur

J'utiliserai un modèle de base pour le modèle utilisateur, à partir duquel je réutiliserai les méthodes CRUD. Bien que j'aie déjà abordé la création de modèles dans un autre article , j'inclurai le modèle de base ici pour une meilleure visibilité et compréhension. Créer dans 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(); } }

Modèle de base


Avec le modèle de base, il faut pouvoir créer UserModel.ts dans le même dossier :

 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 }); } }

Modèle utilisateur


Dans le modèle d'utilisateur, je définis role par défaut s'il n'est pas fourni par la charge utile. Et maintenant que nos modèles sont prêts, nous pouvons procéder à leur utilisation dans nos contrôleurs et middlewares.

Middleware d'authentification

Le middleware d'authentification d'une application Node.js est responsable de l'authentification des demandes entrantes, garantissant qu'elles proviennent d'utilisateurs valides et autorisés.


Il intercepte généralement les demandes entrantes, extrait les jetons d'authentification ou les informations d'identification et vérifie leur validité par rapport à un mécanisme d'authentification prédéfini, tel que JWT (JSON Web Tokens) dans ce cas.


Si le processus d'authentification réussit, le middleware permet à la demande de passer au gestionnaire suivant dans le cycle demande-réponse. Cependant, si l'authentification échoue, il répond avec un code d'état HTTP approprié (par exemple, 401 Unauthorized) et fournit éventuellement un message d'erreur.


Créez le dossier src/middlewares , et ajoutez-y un fichier authMiddleware.ts avec le code suivant :

 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); } }

Fichier middleware d'authentification


Le middleware d'authentification extrait le jeton JWT de l'en-tête de la requête, vérifie sa validité à l'aide de la bibliothèque JWT et vérifie si le jeton correspond à celui stocké dans Redis.


Si le jeton est valide et correspond au jeton stocké, le middleware définit la session utilisateur authentifiée sur l'objet de requête ( req.user ) et appelle la fonction next() pour passer le contrôle au prochain middleware ou gestionnaire de route. Sinon, il répond avec un code d'état 401 indiquant un échec d'authentification.

Jetons Web JSON

Passons en revue l'utilitaire pour jwt. Créez dans le fichier src/utils/jwt.ts avec le code suivant :

 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); } }); }, };

Fichier utilitaire JWT


Cet utilitaire joue un rôle essentiel dans la gestion des jetons Web JSON au sein de l'application Node.js. L'objet jwt exporte des fonctions de signature et de vérification des JWT, en tirant parti de la bibliothèque jsonwebtoken . Ces fonctions facilitent la création et la validation des JWT, indispensables à la mise en œuvre des mécanismes d'authentification dans l'application.


L'utilitaire encapsule la fonctionnalité de gestion des JWT, garantissant des mécanismes d'authentification sécurisés au sein de l'application Node.js tout en adhérant aux meilleures pratiques en matière de gestion des variables d'environnement.

Rédis

Utilisé comme base de données, cache et courtier de messages. Couramment utilisé dans divers cas d'utilisation, notamment la mise en cache, la gestion de sessions, l'analyse en temps réel, les files d'attente de messagerie, les classements, etc.


La vérification du jeton depuis Redis sert de couche supplémentaire de sécurité et de validation pour le jeton JWT. Passons aux paramètres. Pour cela, créez dans le fichier src/redis/index.ts avec le code suivant :

 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 };

Magasin de sessions Redis


Par Redis, nous allons stocker et gérer les jetons de session utilisateur. Dans le middleware d'authentification, après avoir vérifié l'authenticité du jeton JWT, le middleware vérifie si le jeton existe et correspond à celui stocké dans Redis pour la session utilisateur correspondante. Cela permet de garantir que seuls les utilisateurs valides et autorisés peuvent accéder aux itinéraires protégés.


Redis est utilisé comme magasin clé-valeur pour conserver les jetons de session utilisateur. Lorsqu'un utilisateur se connecte ou s'authentifie, son jeton de session est stocké dans Redis. Cela permet une récupération efficace et rapide des jetons de session lors des contrôles d'authentification ultérieurs.


Redis est utilisé dans le middleware d'authentification pour une gestion efficace des sessions, tandis que le fichier lié à Redis gère la configuration et la connexion au serveur Redis et fournit des fonctions pour interagir avec Redis dans d'autres parties de l'application.


Cette configuration garantit des mécanismes d'authentification sécurisés et fiables, avec des jetons de session utilisateur stockés et gérés dans Redis.


La dernière partie est que nous devons nous connecter à Redis dans notre point d'entrée :

 // 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); } })();

Se connecter à Redis


Après avoir terminé la préparation de l’authentification, nous pouvons maintenant nous concentrer sur les contrôleurs.

Contrôleurs

Les contrôleurs dans les routes aident à organiser la logique de l'application en séparant les préoccupations et en favorisant la maintenabilité du code. Nous avons déjà créé le contrôleur pour le contrôle de santé. Ensuite, nous procéderons à la création de contrôleurs pour gérer les opérations avec l'utilisateur.


Le premier contrôleur que nous allons prendre est sessionController.ts qui doit être dans src/controllers avec le code suivant :

 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); } };

Contrôleur de session


Ce contrôleur sert à gérer un point de terminaison lié à la session, probablement responsable de la récupération des informations sur l'utilisateur actuellement authentifié. Nous avons besoin de ce contrôleur pour les raisons suivantes :


Informations sur la session utilisateur : ce contrôleur permet à l'application de récupérer des informations sur la session de l'utilisateur, telles que son profil utilisateur ou d'autres données pertinentes. Ces informations peuvent être utiles pour personnaliser l'expérience utilisateur ou fournir un contenu personnalisé basé sur le profil de l'utilisateur.


Authentification et autorisation : en vérifiant si req.user existe, le contrôleur garantit que seuls les utilisateurs authentifiés peuvent accéder au point de terminaison. Cela permet d'appliquer les règles d'authentification et d'autorisation, garantissant que les données utilisateur sensibles ne sont accessibles qu'aux utilisateurs autorisés.


Récupération du profil utilisateur : le contrôleur interroge la base de données (à l'aide du UserModel ) pour récupérer les informations de l'utilisateur en fonction de son ID de session. Cela permet à l'application de récupérer dynamiquement des données spécifiques à l'utilisateur, offrant ainsi une expérience personnalisée à chaque utilisateur. Cette partie peut certainement être améliorée par le cache 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); } };

Fichier de contrôleur de session avec session définie par Redis


Nous définissons une constante CACHE_EXPIRATION pour spécifier le délai d'expiration du cache en secondes. Dans cet exemple, il est défini sur 3 600 secondes (1 heure). Les données mises en cache sont périodiquement actualisées, empêchant ainsi les données obsolètes d'être transmises aux utilisateurs et préservant l'intégrité des données dans le cache.


Avant de procéder à la création du signUpController , qui gère le processus d'inscription des nouveaux utilisateurs dans notre application, passons en revue le schéma :

Schéma du processus d'inscription

Dans notre cas, lorsque nous essayons de nous inscrire avec un e-mail existant dans la base de données, nous accordons la priorité à la confidentialité des utilisateurs en ne révélant pas explicitement si l'utilisateur existe. Au lieu de cela, nous informons le client avec un message générique indiquant Invalid email or password .


Cette approche encourage le client à soumettre des informations d'identification valides sans divulguer d'informations inutiles sur les utilisateurs existants.


Créons maintenant src/controllers/auth/signUpController.ts et ajoutons le code suivant :

 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); } }

Contrôleur d'inscription


Le contrôleur reçoit une demande contenant l'adresse e-mail et le mot de passe de l'utilisateur, généralement à partir d'un formulaire d'inscription. Il valide les données entrantes par rapport à un userSchema prédéfini pour garantir qu'elles répondent au format requis.


Si la validation réussit, indiquant aucun utilisateur existant et des champs valides, le contrôleur procède au hachage du mot de passe à l'aide de bcrypt.hash , génère un username et crée l'utilisateur à l'aide UserModel.create .


Enfin, il génère un token à l'aide jwt , définit les données session dans Redis et renvoie le token à l'utilisateur.


Concentrons-nous maintenant sur la création d'un contrôleur de connexion. Créez le fichier 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); } }

Contrôleur de connexion


Essentiellement, nous commençons par valider les champs fournis puis vérifions l'existence d'un utilisateur. Si aucun utilisateur n'est trouvé, nous répondons avec un code d'état 400 accompagné du message Invalid email or password , similaire au comportement du signupController .


Si un utilisateur existe, nous comparons le mot de passe fourni avec le mot de passe haché stocké dans la base de données à l'aide de bcrypt.compare .


Si les mots de passe ne correspondent pas, nous répondons avec le message familier « E-mail ou mot de passe invalide ». Enfin, une fois l'authentification réussie, nous générons un jeton, définissons la session dans Redis et renvoyons le jeton au client.


Passons en revue nos contrôleurs protégés, qui dépendent de la présence d'un user_id obtenu à partir du middleware. Nous nous appuyons systématiquement sur cet user_id pour les opérations au sein de ces contrôleurs. Dans les cas où la demande n'a pas d'en-tête authorization , nous devons répondre avec un code d'état 401 .


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


Créez le fichier src/controllers/user/logoutController.ts avec le code suivant :


 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); } }

Contrôleur de déconnexion


Ce logoutController est responsable de la déconnexion d'un utilisateur du système. Dès réception d'une demande, il interagit avec le client Redis pour supprimer la session associée au user.id . Si l'opération réussit, il répond avec un code d'état 200 pour indiquer une déconnexion réussie.


Cependant, si une erreur se produit pendant le processus, il répond avec un code d'état 500 pour signaler une erreur interne du serveur.


Parlons ensuite de la suppression des données utilisateur.


Créez src/controllers/user/deleteUserController.ts et ajoutez ce code :

 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); } };

Supprimer le contrôleur utilisateur


Lorsqu'une requête est reçue, il extrait l'ID utilisateur de l'objet de requête, généralement obtenu à partir du middleware d'authentification.


Par la suite, il procède à la suppression de la session associée à cet user_id de Redis à l'aide du client Redis. Ensuite, il appelle la méthode delete du UserModel pour supprimer les données de l'utilisateur de la base de données.


En cas de suppression réussie de la session et des données utilisateur, il répond avec un code d'état 200 pour indiquer une suppression réussie. En cas d'erreur lors du processus de suppression, il répond avec un code d'état 500 pour signifier une erreur interne du serveur.


Pour mettre à jour les données utilisateur dans le système, créez src/controllers/user/updateUserController.ts et ajoutez le code suivant au fichier :

 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); } };

Mettre à jour le contrôleur utilisateur


Dès réception d'une requête, il extrait les champs first_name , last_name et username du corps de la requête. Ensuite, il filtre ces champs à l'aide de la fonction utilitaire filterObject pour garantir que seuls les champs valides sont inclus dans la charge utile.


Par la suite, il vérifie si le username fourni existe déjà dans la base de données. Si c'est le cas, le contrôleur répond avec un code d'état 400 et un message d'erreur indiquant un username non valide. Si le username est unique, le contrôleur procède à la mise à jour des données utilisateur dans la base de données à l'aide de la méthode updateOneById de UserModel .


Une fois la mise à jour réussie, il répond avec un code d'état 200 et les données utilisateur mises à jour. En cas d'erreur pendant le processus de mise à jour, le contrôleur répond avec un code d'état 500 pour signifier une erreur interne du serveur.


La dernière sera de mettre à jour le mot de passe, à peu près la même idée que la mise à jour des données utilisateur, mais avec hachage du nouveau mot de passe. Créez le dernier contrôleur de notre liste src/controllers/user/updatePasswordController.ts et ajoutez le code :


 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); } };

Mettre à jour le contrôleur de mot de passe


Dès réception d'une demande, il extrait le nouveau mot de passe du corps de la demande. Il vérifie ensuite si un mot de passe est fourni dans le corps de la requête. Sinon, il répond avec un code d'état 400 , indiquant une mauvaise demande. Ensuite, il hache le nouveau mot de passe à l'aide de la bibliothèque bcrypt avec un facteur de sel de 10.


Le mot de passe haché est ensuite stocké de manière sécurisée dans la base de données à l'aide de la méthode updateOneById du UserModel , en l'associant au user.id . Une fois la mise à jour réussie du mot de passe, le contrôleur répond avec un code d'état 200 et un objet JSON contenant l'ID de l'utilisateur.


En cas d'erreur lors du processus de mise à jour du mot de passe, le contrôleur répond avec un code d'état 500 pour indiquer une erreur interne du serveur comme dans les autres contrôleurs.


Assurez-vous de revoir et de configurer l'assistant de validation et les utilitaires à partir du Dépôt GitHub . Une fois configuré, vous devriez être prêt à tester les points de terminaison.


Vérifions le point de terminaison de l'inscription :


Inscription authentifiée


Comme évident, nous avons obtenu un jeton, qui sera utilisé dans l'en-tête pour récupérer la session.


Résultat de la séance en réponse


Nous avons envoyé le jeton d'autorisation dans l'en-tête au serveur et, en réponse, le serveur nous a fourni les données utilisateur extraites de la base de données.


N'hésitez pas à explorer et expérimenter les fonctionnalités de sécurité et la mise en cache Redis. Une fois le modèle de base en place, vous pouvez accéder à des fonctionnalités supplémentaires, telles que la récupération de compte pour les utilisateurs qui oublient leur mot de passe. Ce sujet sera cependant réservé pour un prochain article.

Conclusion

La gestion du flux de routage et d’authentification des utilisateurs de manière évolutive peut s’avérer difficile. Bien que nous ayons mis en œuvre un middleware pour sauvegarder les itinéraires, il existe des stratégies supplémentaires disponibles pour améliorer les performances et la fiabilité du service.


L'expérience utilisateur est encore améliorée grâce à la fourniture de messages d'erreur plus clairs, car la gestion des erreurs reste un aspect important qui nécessite une couverture plus complète. Cependant, nous avons mis en œuvre avec succès le flux d'authentification principal, permettant aux utilisateurs de s'inscrire, d'accéder à leurs comptes, de récupérer les données de session, de mettre à jour les informations utilisateur et de supprimer des comptes.


J'espère que vous avez trouvé ce voyage instructif et que vous avez acquis des connaissances précieuses sur l'authentification des utilisateurs.

Ressources

Dépôt GitHub
Knex.js
Exprimer
Créer une application de nœud
Facteur


Également publié ici