Auf meiner Suche nach einer schnellen, intuitiven und optimierten Authentifizierungslösung für meine Node.js-Anwendungen stieß ich auf Szenarien, die eine schnelle Implementierung ohne Beeinträchtigung der Funktionalität erforderten.
Von der Benutzerregistrierung und -anmeldung über die Verwaltung vergessener Passwörter, die Aktualisierung von Benutzerdaten bis hin zur Kontolöschung suchte ich nach einer umfassenden Lösung, die nahtlos durch diese wichtigen Benutzerinteraktionen navigiert.
Daher zielt mein Artikel darauf ab, genau das darzustellen – einen kohärenten Ansatz, der klare Methoden zur Implementierung von Authentifizierung und Caching integriert und so einen robusten und effizienten Benutzerfluss gewährleistet.
Hier umgehen wir die grundlegenden Installationsverfahren und die Modellerstellung und konzentrieren uns direkt auf die Feinheiten der Authentifizierung und des Benutzerflusses. Wir werden im gesamten Artikel alle notwendigen Links zum Abrufen von Konfigurationsdateien einfügen und so einen nahtlosen Zugriff auf die für die Einrichtung erforderlichen Ressourcen gewährleisten.
Für diese Implementierung nutzen wir Node.js Version 20.11.1 neben Knex, Express und Redis. Darüber hinaus nutzen wir PostgreSQL als unsere Datenbank, die für eine nahtlose Verwaltung mit Docker containerisiert und orchestriert wird.
Der Name unserer Anwendung lautet user-flow-boilerplate
. Lassen Sie uns diesen Ordner erstellen und darin npm init -y
ausführen, um die grundlegende package.json
zu generieren
{ "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" }
Ursprüngliches package.json
Der nächste Schritt besteht darin, die notwendigen Abhängigkeiten hinzuzufügen:
Abhängigkeiten : npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator
DevDependencies :
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
und fügen Sie Skripte hinzu, die unsere Anwendung erstellen und ausführen:
"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" },
Um den reibungslosen Start unserer Anwendung zu gewährleisten, ist es wichtig, einen src
Ordner zu erstellen und darin unsere anfängliche Einstiegspunktdatei, index.ts
, zu platzieren.
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); } })();
Entrypoint-Datei
Für die Entwicklung benötigen wir Einstellungen für typscript
, lint
, jest
, bable
, prettier
und nodemon
. Alle diese Dateien habe ich im folgenden Artikel beschrieben: Erstellen eines Node.js-Servers mit Postgres und Knex auf Express .
Nachdem Sie alle Einstellungen konfiguriert und den Einstiegspunkt erstellt haben, sollte die Ausführung von npm run dev
den Server starten und Sie sollten mit einer Ausgabe wie der folgenden rechnen:
./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
Navigieren Sie als Nächstes zuGET
Anfrage hinzu, drücken Sie cmd + E
(auf dem Mac, aber die Tasten hängen von Ihrem Betriebssystem ab) und benennen Sie sie als health
.
Eingabe für URL hinzufügen: {{BASE_URI}}/health
. Fügen Sie für BASE_URI
eine neue Variable hinzu, die Sie in der gesamten Sammlung verwenden werden: http://localhost:9999/api/v1
Klicken Sie anschließend einfach auf die Schaltfläche „Senden“ und Sie sollten den Antworttext sehen:
{ "message": "OK" }
Bevor wir fortfahren, ist es wichtig, dass unsere Datenbank betriebsbereit ist. Dies erreichen wir, indem wir es mit docker-compose
starten. Um auf die Datenbank zuzugreifen und sie zu verwalten, können Sie verschiedene Entwicklungsplattformen wie verwenden
Persönlich bevorzuge ich die Verwendung
Wir benötigen .env
Datei mit den erforderlichen Schlüsseln, Passwörtern und Testnamen:
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 für die Verbindung zur Datenbank, Redis und Testwerte für Seeds
Keine Angst, ich habe JWT_SECRET
zufällig generiert, um es authentischer zu veranschaulichen. Erstellen wir also eine docker-compose.yml
Datei im Stammverzeichnis des Projekts:
version: '3.6' volumes: data: services: database: build: context: . dockerfile: postgres.dockerfile image: postgres:latest container_name: postgres environment: TZ: Europe/Madrid POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} networks: - default volumes: - data:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped redis: image: redis:latest container_name: redis command: redis-server --requirepass ${REDIS_PASSWORD} networks: - default ports: - "6379:6379" restart: unless-stopped
Docker-Compose-Datei mit Diensten
Wir werden in Docker zwei Dienste für eine schnelle Konnektivität einrichten. Ich habe diesen Prozess optimiert, um einen schnellen Zugriff auf die Datenbank oder Redis zu ermöglichen und es uns zu ermöglichen, Daten effizient abzurufen. Lassen Sie uns also diese Dienste docker-compose up
ausführen, und wir müssen in der Lage sein, die Ausgabe nach der folgenden Ausgabe docker ps
zu sehen:
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
Jetzt müssen wir die Datei src/@types/index.ts
erstellen, in der wir unsere Typen für die Anwendung speichern:
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; }
Typen für den Service
Zu diesem Zeitpunkt müssen Sie knexfile.ts
im Stammverzeichnis des Projekts und des Datenbankordners für Verbindungen, Migrationen und Seeds haben.
Ich habe im Artikel „Erstellen eines Node.js-Servers mit Postgres und Knex in Express“ eine ziemlich ausführliche Erklärung hinterlassen, wie man Benutzer migriert und in die Datenbank einfügt, in der wir diese Umgebungsvariablen verwenden.
Ich möchte die Migrationen speziell überprüfen, um sicherzustellen, dass wir auf dem gleichen Stand sind. Wir haben unsere Dienste bereits gestartet und müssen in der Lage sein, die Verbindung zur Datenbank zu überprüfen.
docker exec -it postgres psql -U username_123 user_flow_boilerplate
Wenn die Verbindung gut ist, befinden Sie sich in der psql
Konsole. Ok, wenn die Verbindung keine Probleme hat, sollten wir in der Lage sein, unsere Tabellen dorthin zu migrieren. Führen Sie knex migrate:latest
aus. Dann sollten Sie die neu hinzugefügten Spalten in Ihrer users
in der Datenbank beobachten.
Lassen Sie es uns mit gefälschten Daten knex seed:run
säen und die Tabelle noch einmal überprüfen.
Damit sind wir nun in der Lage, die Datenbank zu manipulieren und Benutzer nach Bedarf hinzuzufügen, zu löschen oder zu aktualisieren.
Schließlich können wir Einstellungen und Vorbereitung vergessen und uns speziell auf den Benutzerfluss konzentrieren. Dafür müssen wir einen Router erstellen. Wir müssen von diesem Router die folgenden Vorgänge verarbeiten: login
, logout
, signup
, delete_user
, update_user
.
Fügen Sie dazu auf src/routes/index.ts
den folgenden Code hinzu:
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' }); });
Routendatei
Wie Sie sehen können, haben wir am Anfang die Route /health
hinzugefügt, die wir bereits überprüft haben. Aktualisieren wir also den Einstiegspunkt, um diese Routen dort anzuwenden. Entfernen Sie zunächst das vorherige get
.
-> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));
und am Anfang der Datei hinzufügen:
import { router } from 'src/routes'; // ... app.use(cors()); app.use('/api/v1', router);
und erstellen Sie den ersten Controller für health
src/controllers/healthController.ts
mit dem Code:
import { Request, Response } from 'express'; export const healthController = (_: Request, res: Response) => res.status(200).send('ok');
Gesundheitskontrolleur
Kommen wir nun zurück zum Router und prüfen wir, was wir den Routen noch hinzufügen müssen. Wir müssen zwei weitere Dateien hinzufügen: authRouter.ts
und 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);
Authentifizierungsrouter
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);
Benutzer-Router
Ich habe diese Logik aus Gründen der Lesbarkeit und der Verantwortung für die Beibehaltung isolierter Funktionalität aufgeteilt. Alle diese Routen benötigen Controller, in denen wir die Logik verwalten.
Authentifizierungs- und Integritätsrouten benötigen keine Authentifizierungs-Middleware, daher sind diese Routen nicht geschützt. Wenn es jedoch keine Übereinstimmung gibt, erhalten wir den Status 404.
router.get('/health', healthController); router.use('/auth', authRouter);
Nachdem wir nun alle Routen festgelegt haben, müssen wir das Benutzermodell festlegen.
Ich werde ein Basismodell für das Benutzermodell verwenden, von dem aus ich CRUD-Methoden wiederverwenden werde. Während ich die Modellerstellung bereits in einem anderen Artikel behandelt habesrc/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(); } }
Basismodell
Mit dem Basismodell müssen wir UserModel.ts
im selben Ordner erstellen können:
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 }); } }
Benutzermodell
Im Benutzermodell lege ich role
nur standardmäßig fest, wenn sie nicht aus der Nutzlast bereitgestellt wird. Und jetzt, da wir unsere Modelle fertig haben, können wir sie in unseren Controllern und Middlewares verwenden.
Die Authentifizierungs-Middleware in einer Node.js-Anwendung ist für die Authentifizierung eingehender Anfragen verantwortlich und stellt sicher, dass sie von gültigen und autorisierten Benutzern stammen.
Typischerweise fängt es eingehende Anfragen ab, extrahiert Authentifizierungstoken oder Anmeldeinformationen und überprüft deren Gültigkeit anhand eines vordefinierten Authentifizierungsmechanismus, wie in diesem Fall JWT (JSON Web Tokens).
Wenn der Authentifizierungsprozess erfolgreich ist, ermöglicht die Middleware, dass die Anfrage an den nächsten Handler im Anfrage-Antwort-Zyklus weitergeleitet wird. Wenn die Authentifizierung jedoch fehlschlägt, antwortet es mit einem entsprechenden HTTP-Statuscode (z. B. 401 Nicht autorisiert) und gibt optional eine Fehlermeldung aus.
Erstellen Sie den Ordner src/middlewares
und fügen Sie dort eine Datei authMiddleware.ts
mit dem folgenden Code hinzu:
import { jwt } from 'src/utils/jwt'; import { Redis } from 'src/redis'; import type { Request, Response, NextFunction } from 'express'; import type { UserSession } from 'src/@types'; export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; const JWT_SECRET = process.env.JWT_SECRET; if (!token) return res.sendStatus(401); if (!JWT_SECRET) { console.error('JWT_SECRET Not Found'); return res.sendStatus(500); } if (!token) return res.status(401).json({ error: 'Token not provided' }); try { const userSession = await jwt.verify<UserSession>(token); if (!userSession) { return res.sendStatus(401); } const storedToken = await Redis.getSession(userSession.id); if (!storedToken || storedToken !== token) { return res.sendStatus(401); } req.user = userSession; next(); } catch (error) { console.error('JWT_ERROR', error); return res.sendStatus(401); } }
Auth-Middleware-Datei
Die Authentifizierungs-Middleware extrahiert das JWT-Token aus dem Anforderungsheader, überprüft seine Gültigkeit mithilfe der JWT-Bibliothek und prüft, ob das Token mit dem in Redis gespeicherten Token übereinstimmt.
Wenn das Token gültig ist und mit dem gespeicherten Token übereinstimmt, setzt die Middleware die authentifizierte Benutzersitzung auf das Anforderungsobjekt ( req.user
) und ruft die Funktion next()
auf, um die Steuerung an die nächste Middleware oder den nächsten Routenhandler zu übergeben. Andernfalls antwortet es mit dem Statuscode 401, der auf einen Authentifizierungsfehler hinweist.
Sehen wir uns das Dienstprogramm für JWT an. Erstellen Sie die Datei src/utils/jwt.ts
mit dem folgenden Code:
require('dotenv').config(); import jsonwebtoken from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET as string; export const jwt = { verify: <Result>(token: string): Promise<Result> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => { if (error) { reject(error); } else { resolve(decoded as Result); } }); }); }, sign: (payload: string | object | Buffer): Promise<string> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { try { resolve(jsonwebtoken.sign(payload, JWT_SECRET)); } catch (error) { reject(error); } }); }, };
JWT-Dienstprogrammdatei
Dieses Dienstprogramm spielt eine entscheidende Rolle bei der Verarbeitung von JSON-Web-Tokens innerhalb der Node.js-Anwendung. Das jwt
Objekt exportiert Funktionen zum Signieren und Überprüfen von JWTs und nutzt dabei die jsonwebtoken
Bibliothek. Diese Funktionen erleichtern die Erstellung und Validierung von JWTs, die für die Implementierung von Authentifizierungsmechanismen in der Anwendung unerlässlich sind.
Das Dienstprogramm kapselt die Funktionalität für die Handhabung von JWTs und gewährleistet sichere Authentifizierungsmechanismen innerhalb der Node.js-Anwendung unter Einhaltung bewährter Methoden für die Verwaltung von Umgebungsvariablen.
Wird als Datenbank, Cache und Nachrichtenbroker verwendet. Wird häufig in einer Vielzahl von Anwendungsfällen verwendet, darunter Caching, Sitzungsverwaltung, Echtzeitanalysen, Messaging-Warteschlangen, Bestenlisten und mehr.
Die Überprüfung des Tokens von Redis dient als zusätzliche Sicherheits- und Validierungsebene für das JWT-Token. Lassen Sie uns in die Einstellungen eintauchen. Erstellen Sie dazu in der Datei src/redis/index.ts
den folgenden Code:
require('dotenv').config({ path: '../../.env', }); import process from 'process'; import * as redis from 'redis'; const client = redis.createClient({ url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, }); client.on('error', error => console.error('Redis Client Error', error)); const connect = async () => { try { await client.connect(); console.log('Connected to Redis'); } catch (err) { console.error(`Could not connect to Redis: ${err}`); process.exit(1); } }; class Redis { public static setSession(userId: number, token: string) { if (!userId) throw new Error('userId is required'); if (!token) throw new Error('token is required'); try { return client.set(`session:${userId}`, token); } catch (error) { console.error(error); } } public static getSession(userId: number) { if (!userId) throw new Error('userId is required'); return client.get(`session:${userId}`); } public static deleteSession(userId: string) { if (!userId) throw new Error('userId is required'); try { return client.del(`session:${userId}`); } catch (error) { console.error(error); } } } export { client, connect, Redis };
Redis-Sitzungsspeicher
Mit Redis werden wir Benutzersitzungstoken speichern und verwalten. In der Authentifizierungs-Middleware prüft die Middleware nach der Überprüfung der Authentizität des JWT-Tokens, ob das Token vorhanden ist und mit dem in Redis für die entsprechende Benutzersitzung gespeicherten Token übereinstimmt. Dadurch wird sichergestellt, dass nur gültige und autorisierte Benutzer auf geschützte Routen zugreifen können.
Redis wird als Schlüsselwertspeicher zur Verwaltung von Benutzersitzungstoken verwendet. Wenn sich ein Benutzer anmeldet oder authentifiziert, wird sein Sitzungstoken in Redis gespeichert. Dies ermöglicht einen effizienten und schnellen Abruf von Sitzungstoken bei nachfolgenden Authentifizierungsprüfungen.
Redis wird in der Authentifizierungs-Middleware für eine effiziente Sitzungsverwaltung verwendet, während die Redis-bezogene Datei die Konfiguration und Verbindung zum Redis-Server übernimmt und Funktionen für die Interaktion mit Redis in anderen Teilen der Anwendung bereitstellt.
Dieses Setup gewährleistet sichere und zuverlässige Authentifizierungsmechanismen, wobei Benutzersitzungstoken in Redis gespeichert und verwaltet werden.
Der letzte Teil besteht darin, dass wir an unserem Einstiegspunkt eine Verbindung zu Redis herstellen müssen:
// 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); } })();
Stellen Sie eine Verbindung zu Redis her
Nach Abschluss der Authentifizierungsvorbereitung können wir uns nun auf die Controller konzentrieren.
Controller in Routen helfen bei der Organisation der Anwendungslogik, indem sie Bedenken trennen und die Wartbarkeit des Codes fördern. Wir haben den Controller für den Gesundheitscheck bereits erstellt. Als Nächstes erstellen wir Controller für die Abwicklung von Vorgängen mit dem Benutzer.
Der erste Controller, den wir nehmen werden, ist sessionController.ts
, der sich in src/controllers
mit dem folgenden Code befinden muss:
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); } };
Sitzungscontroller
Dieser Controller dient der Verwaltung eines sitzungsbezogenen Endpunkts, der wahrscheinlich für das Abrufen von Informationen über den aktuell authentifizierten Benutzer verantwortlich ist. Wir benötigen diesen Controller aus folgenden Gründen:
Informationen zur Benutzersitzung: Mit diesem Controller kann die Anwendung Informationen über die Sitzung des Benutzers abrufen, beispielsweise sein Benutzerprofil oder andere relevante Daten. Diese Informationen können nützlich sein, um die Benutzererfahrung anzupassen oder personalisierte Inhalte basierend auf dem Benutzerprofil bereitzustellen.
Authentifizierung und Autorisierung: Durch die Prüfung, ob req.user
vorhanden ist, stellt der Controller sicher, dass nur authentifizierte Benutzer auf den Endpunkt zugreifen können. Dies hilft bei der Durchsetzung von Authentifizierungs- und Autorisierungsregeln und stellt sicher, dass vertrauliche Benutzerdaten nur autorisierten Benutzern zugänglich sind.
Benutzerprofilabruf: Der Controller fragt die Datenbank ab (unter Verwendung des UserModel
), um die Informationen des Benutzers basierend auf seiner Sitzungs-ID abzurufen. Dadurch kann die Anwendung benutzerspezifische Daten dynamisch abrufen und so für jeden Benutzer ein maßgeschneidertes Erlebnis bieten. Dieser Teil kann durch den Redis-Cache definitiv verbessert werden:
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); } };
Session-Controller-Datei mit Redis-Set-Sitzung
Wir definieren eine Konstante CACHE_EXPIRATION
, um die Cache-Ablaufzeit in Sekunden anzugeben. In diesem Beispiel ist es auf 3600 Sekunden (1 Stunde) eingestellt. Zwischengespeicherte Daten werden regelmäßig aktualisiert, um zu verhindern, dass veraltete Daten an Benutzer bereitgestellt werden, und um die Datenintegrität im Cache aufrechtzuerhalten.
Bevor wir mit der Erstellung des signUpController
fortfahren, der den Anmeldevorgang für neue Benutzer in unserer Anwendung verwaltet, sehen wir uns das Schema an:
Wenn wir in unserem Fall versuchen, uns mit einer in der Datenbank vorhandenen E-Mail-Adresse anzumelden, legen wir Wert auf die Privatsphäre des Benutzers, indem wir nicht explizit offenlegen, ob der Benutzer existiert. Stattdessen informieren wir den Kunden mit einer allgemeinen Nachricht mit der Meldung Invalid email or password
.
Dieser Ansatz ermutigt den Kunden, gültige Anmeldeinformationen einzureichen, ohne unnötige Informationen über bestehende Benutzer preiszugeben.
Jetzt erstellen wir src/controllers/auth/signUpController.ts
und fügen den folgenden Code hinzu:
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); } }
Registrieren Sie sich als Controller
Der Controller erhält eine Anfrage mit der E-Mail-Adresse und dem Passwort des Benutzers, normalerweise über ein Anmeldeformular. Es validiert die eingehenden Daten anhand eines vordefinierten userSchema
, um sicherzustellen, dass sie dem erforderlichen Format entsprechen.
Wenn die Validierung erfolgreich verläuft und kein vorhandener Benutzer und keine gültigen Felder angezeigt werden, fährt der Controller mit dem Hashen des Kennworts mithilfe von bcrypt.hash
fort, generiert einen username
und erstellt den Benutzer mithilfe von UserModel.create
.
Schließlich generiert es mithilfe von jwt
ein token
, legt die session
in Redis
fest und sendet das token
an den Benutzer zurück.
Konzentrieren wir uns nun auf die Erstellung eines Login-Controllers. Erstellen Sie die Datei 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); } }
Login-Controller
Im Wesentlichen beginnen wir mit der Validierung der bereitgestellten Felder und prüfen dann, ob ein Benutzer vorhanden ist. Wenn kein Benutzer gefunden wird, antworten wir mit einem 400-Statuscode zusammen mit der Meldung Invalid email or password
, ähnlich dem Verhalten im signupController
.
Wenn ein Benutzer vorhanden ist, vergleichen wir mit bcrypt.compare
das bereitgestellte Passwort mit dem in der Datenbank gespeicherten Hash-Passwort.
Wenn die Passwörter nicht übereinstimmen, antworten wir mit der bekannten Meldung „Ungültige E-Mail-Adresse oder ungültiges Passwort“. Schließlich generieren wir nach erfolgreicher Authentifizierung ein Token, richten die Sitzung in Redis ein und senden das Token an den Client zurück.
Sehen wir uns unsere geschützten Controller an, die vom Vorhandensein einer von der Middleware erhaltenen user_id abhängig sind. Wir verlassen uns bei Vorgängen innerhalb dieser Controller konsequent auf diese user_id. In Fällen, in denen der Anfrage ein authorization
fehlt, müssen wir mit einem 401
Statuscode antworten.
const authHeader = req.headers['authorization'];
Erstellen Sie die Datei src/controllers/user/logoutController.ts
mit dem folgenden Code:
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); } }
Logout-Controller
Dieser logoutController
ist für die Abmeldung eines Benutzers vom System verantwortlich. Beim Empfang einer Anfrage interagiert es mit dem Redis-Client, um die mit der user.id
verknüpfte Sitzung zu löschen. Wenn der Vorgang erfolgreich ist, antwortet es mit dem Statuscode 200
, um die erfolgreiche Abmeldung anzuzeigen.
Sollte während des Vorgangs jedoch ein Fehler auftreten, antwortet es mit einem 500
Statuscode, um einen internen Serverfehler zu signalisieren.
Kommen wir als Nächstes zum Löschen von Benutzerdaten.
Erstellen Sie src/controllers/user/deleteUserController.ts
und fügen Sie diesen Code hinzu:
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); } };
Benutzercontroller löschen
Wenn eine Anfrage empfangen wird, extrahiert es die Benutzer-ID aus dem Anfrageobjekt, das normalerweise von der Authentifizierungs-Middleware abgerufen wird.
Anschließend wird die mit dieser user_id
verknüpfte Sitzung mithilfe des Redis-Clients aus Redis gelöscht. Anschließend ruft es die delete
des UserModel
auf, um die Daten des Benutzers aus der Datenbank zu entfernen.
Nach erfolgreichem Löschen sowohl der Sitzung als auch der Benutzerdaten antwortet es mit dem Statuscode 200
, um den erfolgreichen Löschvorgang anzuzeigen. Tritt während des Löschvorgangs ein Fehler auf, antwortet er mit dem Statuscode 500
, der auf einen internen Serverfehler hinweist.
Um die Benutzerdaten im System zu aktualisieren, erstellen Sie src/controllers/user/updateUserController.ts
und fügen Sie der Datei den folgenden Code hinzu:
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); } };
Benutzercontroller aktualisieren
Beim Empfang einer Anfrage werden die Felder first_name
, last_name
und username
aus dem Anfragetext extrahiert. Anschließend werden diese Felder mithilfe der Dienstprogrammfunktion filterObject
gefiltert, um sicherzustellen, dass nur gültige Felder in der Nutzlast enthalten sind.
Anschließend wird geprüft, ob der angegebene username
bereits in der Datenbank vorhanden ist. Wenn dies der Fall ist, antwortet der Controller mit einem 400
Statuscode und einer Fehlermeldung, die auf einen ungültigen username
hinweist. Wenn der username
eindeutig ist, aktualisiert der Controller die Benutzerdaten in der Datenbank mithilfe der updateOneById
Methode des UserModel
.
Bei erfolgreicher Aktualisierung antwortet es mit einem 200
Statuscode und den aktualisierten Benutzerdaten. Treten während des Aktualisierungsvorgangs Fehler auf, antwortet der Controller mit dem Statuscode 500
, um einen internen Serverfehler anzuzeigen.
Der letzte Schritt besteht darin, das Passwort zu aktualisieren. Dies entspricht im Wesentlichen dem Aktualisieren der Benutzerdaten, jedoch mit Hashing des neuen Passworts. Erstellen Sie den letzten Controller aus unserer Liste src/controllers/user/updatePasswordController.ts
und fügen Sie den Code hinzu:
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); } };
Passwort-Controller aktualisieren
Beim Empfang einer Anfrage wird das neue Passwort aus dem Anfragetext extrahiert. Anschließend wird geprüft, ob im Anforderungstext ein Kennwort angegeben ist. Wenn nicht, antwortet es mit dem Statuscode 400
, was auf eine fehlerhafte Anfrage hinweist. Als nächstes wird das neue Passwort mithilfe der bcrypt
Bibliothek mit einem Salt-Faktor von 10 gehasht.
Das gehashte Passwort wird dann mithilfe der updateOneById
Methode des UserModel
sicher in der Datenbank gespeichert und mit der user.id
verknüpft. Bei erfolgreicher Passwortaktualisierung antwortet der Controller mit einem 200
Statuscode und einem JSON-Objekt, das die Benutzer-ID enthält.
Bei Fehlern während der Passwortaktualisierung antwortet der Controller mit einem 500
Statuscode, um wie bei anderen Controllern einen internen Serverfehler anzuzeigen.
Stellen Sie sicher, dass Sie den Validierungshelfer und die Dienstprogramme von überprüfen und einrichten
Lassen Sie uns den Anmeldeendpunkt überprüfen:
Wie ersichtlich, haben wir ein Token erhalten, das im Header zum Abrufen der Sitzung verwendet wird.
Wir haben das Autorisierungstoken im Header an den Server gesendet und als Antwort stellte uns der Server die aus der Datenbank abgerufenen Benutzerdaten zur Verfügung.
Fühlen Sie sich frei, Sicherheitsfunktionen und Redis-Caching zu erkunden und damit zu experimentieren. Wenn das Grundmodell eingerichtet ist, können Sie sich mit zusätzlichen Funktionen befassen, beispielsweise mit der Kontowiederherstellung für Benutzer, die ihre Passwörter vergessen haben. Dieses Thema bleibt jedoch einem zukünftigen Artikel vorbehalten.
Die skalierbare Verwaltung des Routing- und Benutzerauthentifizierungsflusses kann eine Herausforderung sein. Während wir Middleware zum Schutz von Routen implementiert haben, stehen zusätzliche Strategien zur Verbesserung der Leistung und Zuverlässigkeit des Dienstes zur Verfügung.
Durch die Bereitstellung klarerer Fehlermeldungen wird das Benutzererlebnis weiter verbessert, da die Fehlerbehandlung ein wichtiger Aspekt bleibt, der eine umfassendere Abdeckung erfordert. Wir haben jedoch den primären Authentifizierungsablauf erfolgreich implementiert, der es Benutzern ermöglicht, sich anzumelden, auf ihre Konten zuzugreifen, Sitzungsdaten abzurufen, Benutzerinformationen zu aktualisieren und Konten zu löschen.
Ich hoffe, dass Sie diese Reise aufschlussreich fanden und wertvolle Erkenntnisse zur Benutzerauthentifizierung gewonnen haben.
Auch hier veröffentlicht