Crearea unui API REST Node.js/TypeScript, partea 3: MongoDB, autentificare și teste automate

Publicat: 2022-03-11

În acest moment al seriei noastre despre cum să creăm un API REST Node.js cu Express.js și TypeScript, am construit un back-end funcțional și am separat codul nostru în configurație de rută, servicii, middleware, controlere și modele. Dacă sunteți gata să urmați de acolo, clonați exemplul de depozit și rulați git checkout toptal-article-02 .

O API REST cu Mongoose, autentificare și testare automată

În acest al treilea și ultim articol, vom continua să dezvoltăm API-ul nostru REST adăugând:

  • Mongoose pentru a ne permite să lucrăm cu MongoDB și să înlocuim DAO-ul nostru în memorie cu o bază de date reală.
  • Capacități de autentificare și permisiuni, astfel încât consumatorii API să poată utiliza un JSON Web Token (JWT) pentru a accesa punctele noastre finale în siguranță.
  • Testare automată folosind Mocha (un cadru de testare), Chai (o bibliotecă de afirmații) și SuperTest (un modul de abstractizare HTTP) pentru a ajuta la verificarea regresiilor pe măsură ce baza de cod crește și se modifică.

Pe parcurs, vom adăuga biblioteci de validare și securitate, vom câștiga ceva experiență cu Docker și vom sugera mai multe subiecte, biblioteci și abilități suplimentare cititorilor ar face bine să exploreze în construirea și extinderea propriilor API-uri REST.

Instalarea MongoDB ca container

Să începem prin a înlocui baza noastră de date în memorie din articolul anterior cu una reală.

Pentru a crea o bază de date locală pentru dezvoltare, am putea instala MongoDB local. Dar diferențele dintre medii (distribuții și versiuni ale sistemului de operare, de exemplu) pot prezenta probleme. Pentru a evita acest lucru, vom folosi această oportunitate pentru a folosi un instrument standard din industrie: containerul Docker.

Singurul lucru pe care trebuie să-l facă cititorii este să instaleze Docker și apoi să instaleze Docker Compose. Odată instalat, rularea docker -v într-un terminal ar trebui să producă un număr de versiune Docker.

Acum, pentru a rula MongoDB, la rădăcina proiectului nostru vom crea un fișier YAML numit docker-compose.yml care conține următoarele:

 version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"

Docker Compose ne permite să rulăm mai multe containere simultan cu un fișier de configurare. La sfârșitul acestui articol, ne vom uita la rularea back-end-ului nostru REST API în Docker, dar deocamdată, îl vom folosi doar pentru a rula MongoDB fără a fi nevoie să îl instalăm local:

 sudo docker-compose up -d

Comanda up va porni containerul definit, ascultând pe portul standard MongoDB 27017. Comutatorul -d va detașa comanda de la terminal. Dacă totul rulează fără probleme, ar trebui să vedem un mesaj ca acesta:

 Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done

De asemenea, va crea un nou director de data în rădăcina proiectului, așa că ar trebui să adăugăm o linie de data în .gitignore .

Acum, dacă trebuie să închidem containerul nostru MongoDB Docker, trebuie doar să rulăm sudo docker-compose down și ar trebui să vedem următoarea ieșire:

 Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default

Acesta este tot ce trebuie să știm pentru a porni back-end-ul nostru Node.js/MongoDB REST API. Să ne asigurăm că am folosit sudo docker-compose up -d astfel încât MongoDB să fie gata pentru utilizarea aplicației noastre.

Utilizarea Mongoose pentru a accesa MongoDB

Pentru a comunica cu MongoDB, back-end-ul nostru va folosi o bibliotecă de modelare a datelor obiect (ODM) numită Mongoose. Deși Mongoose este destul de ușor de utilizat, merită să consultați documentația pentru a afla toate posibilitățile avansate pe care le oferă pentru proiectele din lumea reală.

Pentru a instala Mongoose, folosim următoarele:

 npm i mongoose

Să configuram un serviciu Mongoose pentru a gestiona conexiunea la instanța noastră MongoDB. Deoarece acest serviciu poate fi partajat între mai multe resurse, îl vom adăuga în folderul common al proiectului nostru.

Configurația este simplă. Deși nu este strict obligatoriu, vom avea un obiect mongooseOptions pentru a personaliza următoarele opțiuni de conectare Mongoose:

  • useNewUrlParser : Fără acest set la true , Mongoose afișează un avertisment de depreciere.
  • useUnifiedTopology : documentația Mongoose recomandă setarea la true pentru a utiliza un motor de gestionare a conexiunilor mai nou.
  • serverSelectionTimeoutMS : În scopul UX al acestui proiect demonstrativ, un timp mai scurt decât valoarea implicită de 30 de secunde înseamnă că orice cititor care uită să pornească MongoDB înainte de Node.js va vedea mai devreme feedback util despre acesta, în loc de un back-end aparent care nu răspunde .
  • useFindAndModify : Setarea acestei opțiuni la false evită, de asemenea, un avertisment de depreciere, dar este menționat în secțiunea de deprecieri a documentației, mai degrabă decât printre opțiunile de conectare Mongoose. Mai precis, acest lucru determină Mongoose să folosească o caracteristică MongoDB nativă mai nouă în loc de o lamă Mongoose mai veche.

Combinând aceste opțiuni cu o logică de inițializare și reîncercare, iată fișierul final common/services/mongoose.service.ts :

 import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService();

Asigurați-vă că păstrați clar diferența dintre funcția connect() de la Mongoose și propria noastră funcție de serviciu connectWithRetry() :

  • mongoose.connect() încearcă să se conecteze la serviciul nostru local MongoDB (care rulează cu docker-compose compose ) și va expira după serverSelectionTimeoutMS milisecunde.
  • MongooseService.connectWithRetry() reîncearcă cele de mai sus în cazul în care aplicația noastră pornește, dar serviciul MongoDB nu rulează încă. Deoarece este într-un constructor singleton, connectWithRetry() va fi rulat o singură dată, dar va reîncerca apelul connect() pe termen nelimitat, cu o pauză de retrySeconds secunde ori de câte ori are loc un timeout.

Următorul nostru pas este să înlocuim baza noastră anterioară în memorie cu MongoDB!

Eliminarea bazei de date din memorie și adăugarea MongoDB

Anterior, am folosit o bază de date în memorie pentru a ne permite să ne concentrăm asupra celorlalte module pe care le construiam. Pentru a folosi Mongoose în schimb, va trebui să refactorăm complet users.dao.ts . Vom avea nevoie de încă o declarație de import , pentru a începe:

 import mongooseService from '../../common/services/mongoose.service';

Acum să eliminăm totul din definiția clasei UsersDao , cu excepția constructorului. Putem începe să-l completăm din nou creând Schema de utilizator pentru Mongoose înainte de constructor:

 Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema);

Aceasta definește colecția noastră MongoDB și adaugă o caracteristică specială pe care baza noastră de date în memorie nu o avea: select: false din câmpul pentru password va ascunde acest câmp de fiecare dată când obținem un utilizator sau listăm toți utilizatorii.

Schema noastră de utilizator probabil pare familiară, deoarece este similară cu entitățile noastre DTO. Principala diferență este că definim ce câmpuri ar trebui să existe în colecția noastră MongoDB numită Users , în timp ce entitățile DTO definesc ce câmpuri să accepte într-o solicitare HTTP.

Acea parte a abordării noastre nu se schimbă, prin urmare încă importăm cele trei DTO-uri ale noastre în partea de sus a users.dao.ts . Dar înainte de a implementa operațiunile noastre cu metoda CRUD, ne vom actualiza DTO-urile în două moduri.

Modificarea DTO nr. 1: id vs. _id

Deoarece Mongoose face automat disponibil un câmp _id , vom elimina câmpul id din DTO. Oricum va veni din parametrii din cererea de traseu.

Atenție la faptul că modelele Mongoose furnizează în mod prestabilit un id virtual de captare, așa că am dezactivat acea opțiune de mai sus cu { id: false } pentru a evita confuzia. Dar asta a rupt referința noastră la user.id din middleware-ul nostru pentru utilizator validateSameEmailBelongToSameUser() — avem nevoie de user._id acolo.

Unele baze de date folosesc id -ul convenției, iar altele folosesc _id , deci nu există o interfață perfectă. Pentru exemplul nostru de proiect care folosește Mongoose, pur și simplu am acordat atenție pe care îl folosim în ce moment al codului, dar nepotrivirea va fi în continuare expusă consumatorilor API:

Căile a cinci tipuri de cereri: 1. O solicitare GET neparametrată către /users trece prin controlerul listUsers() și returnează o serie de obiecte, fiecare dintre ele având o cheie _id. 2. O solicitare POST neparametrată către /users trece prin controlerul createUser(), care utilizează o valoare ID nou generată, returnând-o într-un obiect cu o cheie de id. 3. O solicitare neparametrată către /auth trece prin middleware verifyUserPassword(), care face o căutare MongoDB pentru a seta req.body.userId; de acolo, cererea trece prin controlerul createJWT(), care folosește req.body.userId și returnează un obiect cu cheile accessToken și refreshToken. 4. O solicitare neparametrată către /auth/refresh-token trece prin middleware validJWTNeeded(), care setează res.locals.jwt.userId și middleware validRefreshNeeded(), care utilizează res.locals.jwt.userId și face, de asemenea, un Căutare MongoDB pentru a seta req.body.userId; de acolo, calea trece prin același controler și răspuns ca în cazul precedent. 5. O solicitare parametrizată către /users trece prin configurația UsersRoutes, care populează req.params.userId prin Express.js, apoi middleware validJWTNeeded(), care setează res.locals.jwt.userId, apoi alte funcții middleware (care folosesc req. params.userId, res.locals.jwt.userId sau ambele; și/sau faceți o căutare MongoDB și utilizați result._id) și, în final, printr-o funcție UsersController care va folosi req.body.id și va returna fie niciun corp, fie un obiect cu o cheie _id.
Utilizarea și expunerea ID-urilor de utilizator pe parcursul proiectului final REST API. Rețineți că diferitele convenții interne implică surse diferite de date de identificare a utilizatorului: un parametru de solicitare directă, date codificate JWT sau o înregistrare a bazei de date proaspăt preluată.

Lăsăm cititorilor să implementeze una dintre numeroasele soluții reale disponibile la sfârșitul proiectului.

Modificarea DTO nr. 2: Pregătirea permisiunilor bazate pe steaguri

De asemenea, vom redenumi permissionLevel în permissionFlags în DTO-uri pentru a reflecta sistemul de permisiuni mai sofisticat pe care îl vom implementa, precum și definiția de mai sus Mongoose userSchema .

DTO: Dar principiul DRY?

Rețineți, DTO conține doar câmpurile pe care vrem să le transmitem între clientul API și baza noastră de date. Acest lucru poate părea regretabil, deoarece există o oarecare suprapunere între model și DTO, dar aveți grijă să faceți prea mult pentru DRY cu prețul „securității implicite”. Dacă adăugarea unui câmp necesită doar adăugarea acestuia într-un singur loc, dezvoltatorii ar putea să-l expună fără să vrea în API atunci când era menit să fie doar intern. Acest lucru se datorează faptului că procesul nu îi obligă să se gândească la stocarea și transferul de date ca două contexte separate, cu două seturi potențial diferite de cerințe.

Cu modificările noastre DTO făcute, putem implementa operațiunile noastre cu metoda CRUD (după constructorul UsersDao ), începând cu create :

 async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; }

Rețineți că orice trimite consumatorul API pentru permissionFlags prin userFields , apoi îl înlocuim cu valoarea 1 .

În continuare am citit , funcționalitatea de bază pentru a obține un utilizator prin ID, a obține un utilizator prin e-mail și a enumera utilizatorii cu paginare:

 async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); }

Pentru a actualiza un utilizator, o singură funcție DAO va fi suficientă, deoarece funcția subiacentă Mongoose findOneAndUpdate() poate actualiza întregul document sau doar o parte a acestuia. Rețineți că propria noastră funcție va lua userFields fie ca PatchUserDto , fie ca PutUserDto , folosind un tip de unire TypeScript (semnificat prin | ):

 async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; }

Opțiunea new: true îi spune lui Mongoose să returneze obiectul așa cum este după actualizare, mai degrabă decât așa cum a fost inițial.

Delete este concis cu Mongoose:

 async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); }

Cititorii pot observa că fiecare dintre apelurile la funcțiile membru al User este înlănțuit la un apel exec() . Acest lucru este opțional, dar dezvoltatorii Mongoose îl recomandă deoarece oferă urme de stivă mai bune la depanare.

După ce ne codificăm DAO, trebuie să ne actualizăm ușor users.service.ts din ultimul nostru articol pentru a se potrivi cu noile funcții. Nu este nevoie de refactorizare majoră, doar trei retușuri:

 @@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); }

Majoritatea apelurilor de funcții rămân exact aceleași, deoarece atunci când am refactorizat UsersDao , am menținut structura pe care am creat-o în articolul anterior. Dar de ce excepțiile?

  • Folosim updateUserById() atât pentru PUT , cât și pentru PATCH , așa cum am sugerat mai sus. (Așa cum se menționează în partea 2, urmăm implementările tipice REST API în loc să încercăm să aderăm la anumite RFC-uri la literă. Printre altele, aceasta înseamnă că nu solicitările PUT creează noi entități dacă nu există; în acest fel, back-end-ul nostru nu predă controlul generării ID-ului consumatorilor API.)
  • Transmitem limit și parametrii page către getUsers() , deoarece noua noastră implementare DAO îi va folosi.

Structura principală aici este un model destul de robust. De exemplu, poate fi reutilizat dacă dezvoltatorii doresc să schimbe Mongoose și MongoDB cu ceva precum TypeORM și PostgreSQL. Ca mai sus, o astfel de înlocuire ar necesita pur și simplu refactorizarea funcțiilor individuale ale DAO, menținând în același timp semnăturile acestora pentru a se potrivi cu restul codului.

Testarea API-ului nostru REST susținut de Mongoose

Să lansăm API-ul back-end cu npm start . Vom încerca apoi să creăm un utilizator:

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

Obiectul răspuns conține un nou ID de utilizator:

 { "id": "7WYQoVZ3E" }

Ca și în articolul precedent, testele manuale rămase vor fi mai ușoare folosind variabilele de mediu:

 REST_API_EXAMPLE_

Actualizarea utilizatorului arată astfel:

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

Răspunsul ar trebui să înceapă cu HTTP/1.1 204 No Content . (Fără comutatorul --include , niciun răspuns nu ar fi tipărit, ceea ce este în conformitate cu implementarea noastră.)

Dacă acum îl facem pe utilizator să verifice actualizările de mai sus...:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

… răspunsul arată câmpurile așteptate, inclusiv câmpul _id discutat mai sus:

 { "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" }

Există, de asemenea, un câmp special, __v , folosit de Mongoose pentru versiunea; va fi incrementat de fiecare dată când această înregistrare este actualizată.

În continuare, să enumerăm utilizatorii:

 curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

Răspunsul așteptat este același, doar împachetat în [] .

Acum că parola noastră este stocată în siguranță, să ne asigurăm că putem elimina utilizatorul:

 curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

Ne așteptăm din nou la un răspuns 204.

Cititorii s-ar putea întreba dacă câmpul pentru parolă a funcționat corect, deoarece select: false în definiția Schema Mongoose l-a ascuns din rezultatul nostru GET așa cum a fost intenționat. Să repetăm POST -ul inițial pentru a crea din nou un utilizator, apoi verificăm. (Nu uitați să stocați noul ID pentru mai târziu.)

Parole ascunse și depanare directă a datelor cu containerele MongoDB

Pentru a verifica dacă parolele sunt stocate în siguranță (adică, hashing, mai degrabă decât în ​​text simplu), dezvoltatorii pot inspecta datele MongoDB direct. O modalitate este de a accesa clientul CLI standard mongo din containerul Docker care rulează:

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

De acolo, executarea use api-db urmată de db.users.find().pretty() va lista toate datele utilizatorului, inclusiv parolele.

Cei care preferă o interfață grafică pot instala un client MongoDB separat, cum ar fi Robo 3T:

O bară laterală din stânga arată conexiunile la baze de date, fiecare conținând o ierarhie de lucruri precum baze de date, funcții și utilizatori. Panoul principal are file pentru rularea interogărilor. Fila curentă este conectată la baza de date api-db a localhost:27017 cu interogarea „db.getCollection('users').find({})” cu un singur rezultat. Rezultatul are patru câmpuri: _id, parolă, e-mail și __v. Câmpul de parolă începe cu „$argon2$i$v=19$m=4096,t=3,p=1$” și se termină cu sare și hash, separate printr-un semn dolar și codificate în baza 64.
Examinarea datelor MongoDB direct folosind Robo 3T.

Prefixul parolei ( $argon2... ) face parte din formatul șirului PHC și este stocat intenționat nemodificat: Faptul că Argon2 și parametrii săi generali sunt menționați nu l-ar ajuta pe un hacker să determine parolele originale dacă ar reuși să fure Bază de date. Parola stocată poate fi întărită în continuare folosind sărare, o tehnică pe care o vom folosi mai jos cu JWT-urile. Lăsăm ca un exercițiu pentru cititor să aplice sărarea de mai sus și să examineze diferența dintre valorile stocate atunci când doi utilizatori introduc aceeași parolă.

Acum știm că Mongoose trimite cu succes date către baza noastră de date MongoDB. Dar de unde știm că consumatorii noștri API vor trimite datele corespunzătoare în solicitările lor către rutele utilizatorilor noștri?

Adăugarea expres-validator

Există mai multe moduri de a realiza validarea pe teren. În acest articol vom folosi Express-validator, care este destul de stabil, ușor de utilizat și documentat decent. Deși am putea folosi funcționalitatea de validare care vine cu Mongoose, Express-validator oferă funcții suplimentare. De exemplu, vine cu un validator gata de fabricație pentru adrese de e-mail, care în Mongoose ne-ar cere să codificăm un validator personalizat.

Hai să-l instalăm:

 npm i express-validator

Pentru a seta câmpurile pe care dorim să le validăm, vom folosi metoda body() pe care o vom importa la users.routes.config.ts . Metoda body() va valida câmpurile și va genera o listă de erori – stocată în obiectul express.Request – în caz de eșec.

Apoi avem nevoie de propriul nostru middleware pentru a verifica și a folosi lista de erori. Deoarece această logică este probabil să funcționeze în același mod pentru rute diferite, să creăm common/middleware/body.validation.middleware.ts cu următoarele:

 import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware();

Cu asta, suntem gata să gestionăm orice erori generate de funcția body() . Să adăugăm următoarele înapoi în users.routes.config.ts :

 import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';

Acum ne putem actualiza rutele cu următoarele:

 @@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail,

Asigurați-vă că adăugați BodyValidationMiddleware.verifyBodyFieldsErrors în fiecare rută după orice linii body() care sunt prezente, altfel niciuna dintre ele nu va avea efect.

Observați cum ne-am actualizat rutele POST și PUT pentru a folosi expres-validator în loc de funcția noastră proprie validateRequiredUserBodyFields . Deoarece aceste rute erau singurele care foloseau această funcție, implementarea acesteia poate fi ștearsă de pe users.middleware.ts .

Asta e! Cititorii pot reporni Node.js și pot încerca rezultatul folosind clienții lor REST preferați pentru a vedea cum gestionează diverse intrări. Nu uitați să explorați documentația expres-validator pentru posibilități suplimentare; exemplul nostru este doar un punct de plecare pentru validarea cererii.

Datele valide sunt un aspect de asigurat; utilizatorii și acțiunile valide sunt altele.

Flux de autentificare vs. permisiuni (sau „autorizare”)

Aplicația noastră Node.js expune un set complet de users/ puncte finale, permițând consumatorilor API să creeze, să actualizeze și să enumere utilizatori. Dar fiecare punct final permite acces public nelimitat. Este un tipar obișnuit de a împiedica utilizatorii să-și schimbe datele reciproc, iar străinii să acceseze orice punct final pe care nu dorim să fie public.

Există două aspecte principale implicate în aceste restricții și ambele se scurtează la „auth”. Autentificarea se referă la cine provine cererea, iar autorizarea se referă la dacă li se permite să facă ceea ce solicită. Este important să fii conștient de care dintre ele se discută. Chiar și fără formulare scurte, codurile standard de răspuns HTTP reușesc să încurce problema: 401 Unauthorized este despre autentificare și 403 Forbidden este despre autorizare. Vom greși în privința „auth” care înseamnă „autentificare” în numele modulelor și vom folosi „permisiuni” pentru probleme de autorizare.

Chiar și fără formulare scurte, codurile standard de răspuns HTTP reușesc să încurce problema: 401 Unauthorized este despre autentificare și 403 Forbidden este despre autorizare.

Tweet

Există o mulțime de abordări de autentificare de explorat, inclusiv furnizori de identitate terți, cum ar fi Auth0. În acest articol, am ales o implementare care este de bază, dar scalabilă. Se bazează pe JWT.

Un JWT constă din JSON criptat cu unele metadate care nu sunt legate de autentificare, care în cazul nostru include adresa de e-mail a utilizatorului și semnalizatoarele de permisiuni. JSON va conține, de asemenea, un secret pentru a verifica integritatea metadatelor.

Ideea este de a solicita clienților să trimită un JWT valid în cadrul fiecărei cereri non-publice. Acest lucru ne permite să verificăm că clientul a avut recent acreditări valide pentru punctul final pe care dorește să-l folosească, fără a fi nevoie să trimitem ei înșiși acreditările prin cablu cu fiecare solicitare.

Dar unde se va potrivi acest lucru în baza noastră de cod API exemplu? Ușor: cu middleware putem folosi în configurația rutei noastre!

Adăugarea modulului de autentificare

Mai întâi să configuram ce va fi în JWT-urile noastre. Iată de unde vom începe să folosim câmpul permissionFlags din resursa noastră de utilizator, dar numai pentru că sunt metadate convenabile de criptat în JWT - nu pentru că JWT-urile au în mod inerent ceva de-a face cu logica de permisiuni.

Înainte de a crea middleware care generează JWT, va trebui să adăugăm o funcție specială la users.dao.ts pentru a prelua câmpul de parolă, deoarece am setat Mongoose să evite, în mod normal, recuperarea acestuia:

 async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); }

Și în users.service.ts :

 async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); }

Acum, să creăm un folder de auth în rădăcina proiectului nostru - vom adăuga un punct final pentru a permite consumatorilor API să genereze JWT. Mai întâi, să creăm o bucată de middleware pentru aceasta la auth/middleware/auth.middleware.ts , ca un singleton numit AuthMiddleware .

Vom avea nevoie de niște import :

 import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2';

În clasa AuthMiddleware , vom crea o funcție middleware pentru a verifica dacă un utilizator API a inclus acreditări valide de conectare la cererea sa:

 async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); }

În ceea ce privește middleware-ul pentru a ne asigura că e- email și password există în req.body , vom folosi express-validator când vom configura ulterior ruta pentru a utiliza funcția verifyUserPassword() de mai sus.

Stocarea secretelor JWT

Pentru a genera un JWT, vom avea nevoie de un secret JWT, pe care îl vom folosi pentru a semna JWT-urile generate și, de asemenea, pentru a valida JWT-urile primite de la solicitările clientului. În loc să codificăm valoarea secretului JWT într-un fișier TypeScript, o vom stoca într-un fișier separat „variabilă de mediu”, .env , care nu ar trebui să fie trimis niciodată într-un depozit de cod .

După cum este o practică obișnuită, am adăugat un fișier .env.example în depozit pentru a ajuta dezvoltatorii să înțeleagă ce variabile sunt necesare la crearea .env real. În cazul nostru, dorim o variabilă numită JWT_SECRET stocheze secretul nostru JWT ca șir. Cititorii care așteaptă până la sfârșitul acestui articol și folosesc ramura finală a repo-ului vor trebui să -și amintească să schimbe aceste valori la nivel local .

Proiectele din lumea reală vor trebui în special să urmeze cele mai bune practici JWT prin diferențierea secretelor JWT în funcție de mediu (dezvoltare, punere în scenă, producție etc.).

Fișierul nostru .env (în rădăcina proiectului) trebuie să folosească următorul format, dar nu ar trebui să păstreze aceeași valoare secretă:

 JWT_SECRET=My!@!Se3cr8tH4sh3

O modalitate ușoară de a încărca aceste variabile în aplicația noastră este să folosiți o bibliotecă numită dotenv:

 npm i dotenv

Singura configurație necesară este să apelăm funcția dotenv.config() imediat ce lansăm aplicația noastră. În partea de sus a app.ts , vom adăuga:

 import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }

Controlerul de autentificare

Ultima generație JWT condiție este să instalați biblioteca jsonwebtoken și tipurile sale TypeScript:

 npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken

Acum, să creăm controlerul /auth la auth/controllers/auth.controller.ts . Nu trebuie să importam biblioteca dotenv aici, deoarece importul acesteia în app.ts face ca conținutul fișierului .env să fie disponibil în întreaga aplicație prin intermediul obiectului global Node.js numit process :

 import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController();

Biblioteca jsonwebtoken va semna un nou token cu jwtSecret . Vom genera, de asemenea, o sare și un hash folosind modulul crypto nativ Node.js, apoi le vom folosi pentru a crea un refreshToken cu care consumatorii API pot reîmprospăta JWT-ul curent - o configurație care este deosebit de bună de a avea loc pentru o aplicație. să poată scala.

Care este diferența dintre refreshKey , refreshToken și accessToken ? *Token -urile sunt trimise consumatorilor noștri API cu ideea că accessToken -ul este utilizat pentru orice solicitare dincolo de ceea ce este disponibil publicului larg, iar refreshToken este folosit pentru a solicita înlocuirea unui accessToken expirat. refreshKey , pe de altă parte, este folosită pentru a trece variabila salt - criptată în refreshToken - înapoi la middleware-ul nostru de reîmprospătare, la care vom ajunge mai jos.

Rețineți că implementarea noastră are expirarea token-ului de mâner jsonwebtoken pentru noi. Dacă JWT a expirat, clientul va trebui să se autentifice din nou.

Rută inițială de autentificare API REST Node.js

Să configuram punctul final acum la auth/auth.routes.config.ts :

 import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } }

Și nu uitați să îl adăugați în fișierul nostru app.ts :

 // ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...

Suntem gata să repornim Node.js și să testăm acum, asigurându-ne că ne potrivim cu orice acreditări pe care le-am folosit pentru a crea utilizatorul nostru de testare mai devreme:

 curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

Răspunsul va fi ceva de genul:

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }

Ca și înainte, să setăm câteva variabile de mediu pentru comoditate folosind valorile de mai sus:

 REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

Grozav! Avem jetonul nostru de acces și un jeton de reîmprospătare, dar avem nevoie de niște middleware care pot face ceva util cu ele.

JWT Middleware

Vom avea nevoie de un nou tip TypeScript pentru a gestiona structura JWT în forma sa decodificată. Creați common/types/jwt.ts cu asta în el:

 export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; };

Să implementăm funcții middleware pentru a verifica prezența unui jeton de reîmprospătare, pentru a verifica un jeton de reîmprospătare și pentru a verifica un JWT. Toate trei pot intra într-un fișier nou, auth/middleware/jwt.middleware.ts :

 import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware();

The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.

We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)

Now to configure a new route for refreshing the token and the permission flags encoded within it.

JWT Refresh Route

In auth.routes.config.ts we'll import our new middleware:

 import jwtMiddleware from './middleware/jwt.middleware';

Then we'll add the following route:

 this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]);

Now we can test if it is working properly with the accessToken and refreshToken we received earlier:

 curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }"

We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.

Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.

User Permissions

Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.

Bitwise AND ( & ) and Powers of Two

To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.

All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:

  • 1: Authors can edit text.
  • 2: Illustrators can replace illustrations.
  • 4: Narrators can replace the audio file corresponding to any paragraph.
  • 8: Translators can edit translations.

This approach allows for all sorts of permission flag combinations for users:

  • An author's (or editor's) permission flags value will be just the number 1.
  • An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
  • A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
  • A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
  • If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.

Readers can test this logic as plain JavaScript:

  • User with permission 5 trying to edit text (permission flag 1):

Input: 5 & 1

Output: 1

  • User with permission 1 trying to narrate (permission flag 4):

Input: 1 & 4

Output: 0

  • User with permission 12 trying to narrate:

Input: 12 & 4

Output: 4

When the output is 0, we block the user; otherwise, we let them access what they are trying to access.

Permission Flag Implementation

We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :

 export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }

Note: Since this is an example project, we kept the flag names fairly generic.

Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.

We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :

 import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');

Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.

Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:

permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }

Un caz mai personalizat este că singurii utilizatori care ar trebui să poată accesa o anumită înregistrare de utilizator sunt același utilizator sau un administrator:

 async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } }

Vom adăuga o ultimă bucată de middleware, de data aceasta în users.middleware.ts :

 async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } }

Și deoarece funcția de mai sus depinde de res.locals.user , putem popula acea valoare în validateUserExists() înainte de apelul next() :

 // ... if (user) { res.locals.user = user; next(); } else { // ...

De fapt, făcând acest lucru în validateUserExists() nu este necesar în validateSameEmailBelongToSameUser() . Putem elimina apelul nostru la baza de date acolo, înlocuindu-l cu valoarea pe care ne putem baza că este stocată în cache în res.locals :

 - const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) {

Acum suntem gata să integrăm logica noastră de permisiuni în users.routes.config.ts .

Necesită permisiuni

Mai întâi, vom importa noul nostru middleware și enum :

 import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';

Dorim ca lista de utilizatori să fie accesibilă numai prin solicitări făcute de cineva cu permisiuni de administrator, dar dorim totuși ca posibilitatea de a crea un utilizator nou să fie publică, așa cum curge așteptările normale UX. Să restrângem mai întâi lista de utilizatori folosind funcția din fabrică înainte de controlerul nostru:

 this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ...

Amintiți-vă că apelul din fabrică aici ( (...) ) returnează o funcție middleware - prin urmare, toate middleware-urile normale, care nu sunt din fabrică, sunt referite fără invocare ( () ).

O altă restricție comună este aceea că, pentru toate rutele care includ un userId , dorim să aibă acces doar același utilizator sau un administrator:

 .route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById)

De asemenea, vom împiedica utilizatorii să-și escaladeze privilegiile prin adăugarea UsersMiddleware.userCantChangePermission , chiar înainte de referința funcției UsersController la sfârșitul fiecărei rute PUT și PATCH .

Dar să presupunem în continuare că logica noastră de afaceri REST API permite doar utilizatorilor cu PAID_PERMISSION să-și actualizeze informațiile. Acest lucru se poate alinia sau nu cu nevoile de afaceri ale altor proiecte: este doar pentru a testa diferența dintre permisiunea plătită și cea gratuită.

Acest lucru se poate face prin adăugarea unui alt apel de generator după fiecare dintre referințele userCantChangePermission pe care tocmai le-am adăugat:

 permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),

Cu asta, suntem gata să repornim Node.js și să îl încercăm.

Testarea manuală a permisiunilor

Pentru a testa rutele, să încercăm să GET lista de utilizatori fără un token de acces:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

Primim un răspuns HTTP 401 deoarece trebuie să folosim un JWT valid. Să încercăm cu un token de acces de la autentificarea noastră anterioară:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

De data aceasta obținem un HTTP 403. Indicatorul nostru este valid, dar ne este interzis să folosim acest punct final deoarece nu avem ADMIN_PERMISSION .

Totuși, nu ar trebui să avem nevoie de el pentru a GET propria noastră înregistrare de utilizator:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

Raspunsul:

 { "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 }

În schimb, încercarea de a actualiza propria înregistrare de utilizator ar trebui să eșueze, deoarece valoarea permisiunii noastre este 1 (numai FREE_PERMISSION ):

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'

Răspunsul este 403, așa cum era de așteptat.

Ca exercițiu de citire, vă recomand să schimbați permissionFlags utilizatorului în baza de date locală și să faceți o nouă postare la /auth (pentru a genera un token cu noul permissionFlags ), apoi să încercați din nou PATCH -ul utilizatorului. Amintiți-vă că va trebui să setați semnalizatoarele la valoarea numerică fie a PAID_PERMISSION , fie a ALL_PERMISSIONS , deoarece logica noastră de afaceri specifică că ADMIN_PERMISSION în sine nu vă permite să aplicați corecții altor utilizatori sau chiar dvs.

Cerința pentru o nouă postare la /auth aduce la iveală un scenariu de securitate care merită avut în vedere. Când proprietarul unui site modifică permisiunile unui utilizator, de exemplu, pentru a încerca să blocheze un utilizator care se comportă greșit, utilizatorul nu va vedea că acest lucru va intra în vigoare până la următoarea actualizare JWT. Asta pentru că verificarea permisiunilor folosește datele JWT în sine pentru a evita o accesare suplimentară a bazei de date.

Servicii precum Auth0 pot ajuta oferind rotație automată a simbolurilor, dar utilizatorii vor avea în continuare un comportament neașteptat al aplicației în timpul intervalului dintre rotații, oricât de scurt ar fi în mod normal. Pentru a atenua acest lucru, dezvoltatorii trebuie să aibă grijă să revoce în mod activ jetoanele de reîmprospătare ca răspuns la modificările permisiunilor.


În timp ce lucrează la un API REST, dezvoltatorii se pot proteja împotriva erorilor potențiale, reluând periodic o grămadă de comenzi cURL. Dar acest lucru este lent și predispus la erori și devine rapid obositor.

Testare automată

Pe măsură ce un API crește, devine dificil să se mențină calitatea software-ului, mai ales cu schimbarea frecventă a logicii de afaceri. Pentru a reduce erorile API cât mai mult posibil și pentru a implementa noi modificări cu încredere, este foarte obișnuit să aveți o suită de testare pentru front-end și/sau back-end al unei aplicații.

În loc să ne aprofundăm în testele de scriere și codul care poate fi testat, vom arăta câteva mecanisme de bază și vom oferi o suită de teste funcționale pe care cititorii să o poată construi.

Confruntarea cu resturile de date de testare

Înainte de a automatiza, merită să ne gândim la ce se întâmplă cu datele de testare.

Utilizăm Docker Compose pentru a rula baza noastră de date locală, ne așteptăm să folosim această bază de date pentru dezvoltare, nu ca sursă de date de producție live. Testele pe care le vom rula aici vor afecta baza de date locală, lăsând în urmă un nou set de date de testare de fiecare dată când le rulăm. Aceasta nu ar trebui să fie o problemă în majoritatea cazurilor, dar dacă este, le lăsăm cititorilor exercițiul de a schimba docker-compose.yml pentru a crea o nouă bază de date în scopuri de testare.

În lumea reală, dezvoltatorii rulează adesea teste automate ca parte a unei conducte de integrare continuă. Pentru a face acest lucru, ar fi logic să configurați, la nivel de conductă, o modalitate de a crea o bază de date temporară pentru fiecare rulare de testare.

Vom folosi Mocha, Chai și SuperTest pentru a crea testele noastre:

 npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-node

Mocha va gestiona aplicația noastră și va rula testele, Chai va permite o exprimare mai lizibilă a testului, iar SuperTest va facilita testarea end-to-end (E2E) apelând API-ul nostru așa cum ar face un client REST.

Va trebui să ne actualizăm scripturile la package.json :

 "scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" },

Asta ne va permite să rulăm teste într-un folder pe care îl vom crea, numit test .

Un meta-test

Pentru a încerca infrastructura noastră de testare, să creăm un fișier, test/app.test.ts :

 import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); });

Sintaxa de aici poate părea neobișnuită, dar este corectă. Definim testele prin comportamentul expect() în cadrul blocurilor it() — prin care ne referim la corpul unei funcții pe care o vom transmite lui it() — care sunt numite în blocurile describe() .

Acum, la terminal, vom rula:

 npm run test

Ar trebui să vedem asta:

 > mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)

Grozav! Bibliotecile noastre de testare sunt instalate și gata de utilizare.

Raționalizarea testării

Pentru a menține rezultatul de testare curat, vom dori să oprim complet înregistrarea cererilor Winston în timpul testelor normale. Este la fel de ușor ca o schimbare rapidă a ramurii else fără depanare din app.ts pentru a detecta dacă funcția it() de la Mocha este prezentă:

 if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } }

O ultimă atingere pe care trebuie să o adăugăm este să app.ts noastre pentru a fi consumate de testele noastre. La sfârșitul app.ts , vom adăuga export default chiar înainte de server.listen() , deoarece listen() returnează obiectul nostru http.Server

Cu un npm run test a verifica dacă nu am spart stiva, suntem acum gata să testăm API-ul nostru.

Primul nostru test automat API REST real

Pentru a începe configurarea testelor utilizatorilor noștri, să creăm test/users/users.test.ts , începând cu importurile și variabilele de testare necesare:

 import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco';

În continuare, vom crea un bloc describe() cel mai exterior cu câteva definiții de configurare și demontare:

 describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); });

Funcțiile cărora le trecem before() și after() sunt apelate înainte și după toate testele pe care le vom defini apelându- it() în cadrul aceluiași bloc describe() . Funcția transmisă la after() primește un callback, done , care ne asigurăm că este apelată numai după ce am curățat atât aplicația, cât și conexiunea la baza de date.

Notă: Fără tactica noastră after() , Mocha se va bloca chiar și după finalizarea cu succes a testului. Sfatul este deseori să sunați pur și simplu pe Mocha cu --exit pentru a evita acest lucru, dar există o avertizare (deseori nemenționată). Dacă suita de testare s-ar bloca din alte motive, cum ar fi o Promisiune greșită în suita de testare sau aplicația în sine, atunci cu --exit , Mocha nu va aștepta și va raporta succesul oricum, adăugând o complicație subtilă la depanare.

Acum suntem gata să adăugăm teste E2E individuale în blocul describe() :

 it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; });

Această primă funcție va crea un utilizator nou pentru noi - unul unic, deoarece e-mailul nostru de utilizator a fost generat mai devreme folosind shortid . Variabila de request deține un agent SuperTest, permițându-ne să facem solicitări HTTP către API-ul nostru. Le facem folosind await , motiv pentru care funcția pe care o transmitem lui it() trebuie să fie async . Apoi folosim expect() de la Chai pentru a testa diferite aspecte ale rezultatului.

Un npm run test în acest moment ar trebui să arate noul nostru test de funcționare.

Un lanț de teste

Vom adăuga toate următoarele blocuri it() în blocul nostru describe() . Trebuie să le adăugăm în ordinea prezentată, astfel încât să funcționeze cu variabilele pe care le modificăm, cum ar fi firstUserIdTest .

 it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });

Aici obținem un nou token de acces și reîmprospătare pentru utilizatorul nostru nou creat.

 it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); });

Aceasta face o solicitare GET care poartă token către ruta :userId pentru a verifica dacă răspunsul la datele utilizatorului se potrivește cu ceea ce am trimis inițial.

Cuibărire, sărituri, izolare și salvare la teste

În Mocha, blocurile it() pot conține și propriile lor blocuri describe() , așa că vom încadra următorul nostru test într-un alt bloc describe() . Acest lucru va face cascada noastră de dependențe mai clară în rezultatul testului, așa cum vom arăta la sfârșit.

 describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });

Testarea eficientă acoperă nu numai ceea ce ne așteptăm să funcționeze, ci și ceea ce ne așteptăm să eșueze. Aici încercăm să listăm toți utilizatorii și ne așteptăm la un răspuns 403, deoarece utilizatorul nostru (având permisiuni implicite) nu are permisiunea de a utiliza acest punct final.

În acest nou bloc describe() , putem continua să scriem teste. Deoarece am discutat deja despre caracteristicile utilizate în restul codului de testare, acesta poate fi găsit începând cu această linie din depozit.

Mocha oferă câteva caracteristici care pot fi convenabile de utilizat în timpul dezvoltării și depanării testelor:

  1. Metoda .skip() poate fi folosită pentru a evita rularea unui singur test sau a unui întreg bloc de teste. Când it() este înlocuit cu it.skip() (la fel și pentru describe() ), testul sau testele în cauză nu vor fi rulate, dar vor fi numărate ca „în așteptare” în rezultatul final al lui Mocha.
  2. Pentru o utilizare și mai temporară, funcția .only() face ca toate testele marcate non-.only .only() să fie complet ignorate și nu are ca rezultat marcarea nimicului ca „în așteptare”.
  3. Invocarea mocha așa cum este definită în package.json poate folosi --bail ca parametru de linie de comandă. Când este setat, Mocha oprește rularea testelor de îndată ce un test eșuează. Acest lucru este util în special în proiectul nostru exemplu REST API, deoarece testele sunt configurate în cascadă; dacă doar primul test este spart, Mocha raportează exact asta, în loc să se plângă de toate testele dependente (dar nu rupte) care acum eșuează din cauza asta.

Dacă rulăm bateria noastră completă de teste în acest moment cu npm run test , vom vedea trei teste eșuate. (Dacă ar fi să lăsăm funcțiile pe care se bazează neimplementate pentru moment, aceste trei teste ar fi candidați buni pentru .skip() .)

Testele eșuate se bazează pe două piese care lipsesc în prezent din aplicația noastră. Primul este în users.routes.config.ts :

 this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]);

Al doilea fișier pe care trebuie să-l actualizăm este users.controller.ts , deoarece tocmai am făcut referire la o funcție care nu există acolo. Va trebui să adăugăm import { PatchUserDto } from '../dto/patch.user.dto'; aproape de partea de sus și funcția lipsă din clasă:

 async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }

Adăugarea unor astfel de abilități de escaladare a privilegiilor este utilă pentru testare, dar nu se va potrivi cu majoritatea cerințelor din lumea reală. Există două exerciții pentru cititor aici:

  1. Luați în considerare modalități prin care codul să nu permită utilizatorilor să-și schimbe propriile permissionFlags , permițând în același timp testarea punctelor finale cu permisiuni restricționate.
  2. Creați și implementați logica de afaceri (și testele corespunzătoare) pentru modul în care permissionFlags ar trebui să se poată schimba prin API. (Există un puzzle de pui și ouă aici: Cum obține un anumit utilizator permisiunea de a schimba permisiunile în primul rând?)

Cu asta, npm run test ar trebui acum să se termine cu succes cu o ieșire frumos formatată ca aceasta:

 Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)

Acum avem o modalitate de a verifica rapid că API-ul nostru REST funcționează conform așteptărilor.

Depanare (cu) teste

Dezvoltatorii care se confruntă cu eșecuri neașteptate ale testelor pot folosi cu ușurință atât modulul de depanare Winston, cât și modulul Node.js atunci când rulează suita de teste.

De exemplu, este ușor să vă concentrați asupra interogărilor Mongoose care sunt executate prin invocarea DEBUG=mquery npm run test . (Rețineți că acelei comenzi îi lipsesc prefixul de export și && la mijloc, ceea ce ar face ca mediul să persiste la comenzile ulterioare.)

De asemenea, este posibil să afișați toate rezultatele de depanare cu npm run test-debug , datorită adăugării noastre anterioare la package.json .

Cu aceasta, avem o API REST funcțională, scalabilă, susținută de MongoDB, cu o suită de testare automată convenabilă. Dar încă îi lipsesc câteva elemente esențiale.

Securitate (toate proiectele ar trebui să poarte cască)

Când lucrați cu Express.js, documentația este o citire obligatorie, în special cele mai bune practici de securitate. Cel puțin, merită să urmăriți:

  • Configurarea suportului TLS
  • Adăugarea unui middleware care limitează rata
  • Asigurarea că dependențele npm sunt sigure (cititorii ar putea dori să înceapă cu npm audit sau să aprofundeze cu snyk)
  • Utilizarea bibliotecii Helmet pentru a ajuta la protejarea împotriva vulnerabilităților comune de securitate

Acest ultim punct este ușor de adăugat la proiectul nostru exemplu:

 npm i --save helmet

Apoi, în app.ts , trebuie doar să îl importăm și să adăugăm un alt apel app.use() :

 import helmet from 'helmet'; // ... app.use(helmet());

După cum arată documentele sale, Casca (ca orice adăugare de securitate) nu este un glonț de argint, dar fiecare măsură de prevenire ajută.

Conținând proiectul nostru API REST cu Docker

În această serie, nu am intrat în profunzime în containerele Docker, dar am folosit MongoDB într-un container cu Docker Compose. Cititorii care nu sunt familiarizați cu Docker, dar doresc să încerce un pas suplimentar, pot crea un fișier numit Dockerfile (fără extensie) în rădăcina proiectului:

 FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"]

Această configurație începe cu imaginea oficială node:14-slim de la Docker și construiește și rulează exemplul nostru de API REST într-un container. Configurația se poate schimba de la caz la caz, dar aceste valori implicite cu aspect generic funcționează pentru proiectul nostru.

Pentru a construi imaginea, rulăm asta la rădăcina proiectului (înlocuind tag_your_image_here după cum doriți):

 docker build . -t tag_your_image_here

Apoi, o modalitate de a ne rula back end - presupunând exact aceeași înlocuire a textului - este:

 docker run -p 3000:3000 tag_your_image_here

În acest moment, MongoDB și Node.js pot folosi ambele Docker, dar trebuie să le pornim în două moduri diferite. Lăsăm ca un exercițiu pentru cititor să adauge aplicația principală Node.js la docker-compose.yml , astfel încât întreaga aplicație să poată fi lansată cu o singură comandă docker-compose -compose.

Mai multe abilități REST API de explorat

În acest articol, am adus îmbunătățiri extinse API-ului nostru REST: am adăugat un MongoDB containerizat, am configurat Mongoose și un validator expres, am adăugat autentificare bazată pe JWT și un sistem flexibil de permisiuni și am scris o baterie de teste automate.

Acesta este un punct de plecare solid atât pentru dezvoltatorii back-end noi, cât și pentru cei avansați. Cu toate acestea, în anumite privințe, proiectul nostru poate să nu fie ideal pentru utilizare în producție, scalare și întreținere. În afară de exercițiile pentru cititori pe care le-am presărat în acest articol, ce altceva mai este de învățat?

La nivel de API, vă recomandăm să citiți despre crearea unei specificații compatibile cu OpenAPI. Cititorii interesați în mod special de dezvoltarea întreprinderilor vor dori, de asemenea, să încerce NestJS. Este un alt cadru construit pe Express.js, dar este mai robust și mai abstract - de aceea este bine să folosiți proiectul nostru exemplu pentru a vă familiariza mai întâi cu elementele de bază ale Express.js. Nu mai puțin important, abordarea GraphQL a API-urilor are o tracțiune larg răspândită ca alternativă la REST.

Când vine vorba de permisiuni, am acoperit o abordare a steaguri pe biți cu un generator de middleware pentru steaguri definite manual. Pentru mai multă comoditate la scalare, merită să vă uitați în biblioteca CASL, care se integrează cu Mongoose. Extinde flexibilitatea abordării noastre, permițând definiții succinte ale abilităților pe care ar trebui să le permită un anumit semnal, cum ar fi can(['update', 'delete'], '(model name here)', { creator: 'me' }); în locul unei întregi funcții middleware personalizate.

Am furnizat o platformă practică de testare automată în acest proiect, dar unele subiecte importante au depășit domeniul nostru de aplicare. Recomandăm cititorilor:

  1. Explorați testarea unitară pentru a testa componentele separat—Mocha și Chai pot fi folosite și pentru aceasta.
  2. Analizați instrumentele de acoperire a codului, care ajută la identificarea lacunelor în suitele de testare, arătând linii de cod care nu sunt rulate în timpul testării. Cu astfel de instrumente, cititorii pot completa testele de exemplu, după cum este necesar, dar este posibil să nu dezvăluie fiecare scenariu lipsă, cum ar fi dacă utilizatorii își pot modifica permisiunile printr-un PATCH către /users/:userId .
  3. Încercați alte abordări ale testării automate. Am folosit interfața de expect în stilul dezvoltării bazate pe comportament (BDD) de la Chai, dar acceptă și should() și assert . De asemenea, merită să înveți și alte biblioteci de testare, cum ar fi Jest.

Pe lângă aceste subiecte, API-ul nostru REST Node.js/TypeScript este gata pentru a fi construit. În special, cititorii ar putea dori să implementeze mai mult middleware pentru a impune logica de afaceri comună în jurul resursei standard pentru utilizatori. Nu voi aprofunda acest lucru aici, dar aș fi bucuros să ofer îndrumări și sfaturi cititorilor care se simt blocați - doar lăsați un comentariu mai jos.

Codul complet pentru acest proiect este disponibil ca un depozit GitHub open-source.


Citiți suplimentare pe blogul Toptal Engineering:

  • Utilizarea rutelor Express.js pentru gestionarea erorilor bazată pe promisiuni