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.
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 à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
Ensuite, cliquez simplement sur le bouton « Envoyer » et vous devriez observer le corps de la réponse :
{ "message": "OK" }
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
Personnellement, je préfère utiliser
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.
Semons-le avec de fausses données knex seed:run
et vérifions à nouveau le tableau.
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.
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.
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.
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 autresrc/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.
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.
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.
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.
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 :
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
Vérifions le point de terminaison de l'inscription :
Comme évident, nous avons obtenu un jeton, qui sera utilisé dans l'en-tête pour récupérer la session.
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.
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.
Également publié ici