Scopul sedintei:
Filmuletul si prezentarea ppt pot fi gasite la linkul acesta
Scheletul de cod pentru serviciile implementate poate fi gasit pe urmatorul grup de Gitlab
Folosind cunostintele de Rest API invatate la laboratorul trecut, am dezvoltat serviciul de utilizatori, care se ocupa si de partea de autentificare si autorizare.
Multe componente sunt reutilizate complet, partea de securitate fiind, insa, noua.
Cele doua functii scrise in acest fisier vor fi folosite la register (hash) si la login (compare).
const bcryptjs = require('bcryptjs'); //pachet folosit la criptarea parolelor const hash = async (plainTextPassword) => { // functia care va cripta o parola plaintext const salt = await bcryptjs.genSalt(5); // se genereaza salt-ul const hash = await bcryptjs.hash(plainTextPassword, salt); // pe baza saltului si a parolei plainText se genereaza hashul parolei return hash }; const compare = async (plainTextPassword, hashedPassword) => { // functie care compara o parola text cu o parola hashuita const isOk = await bcryptjs.compare(plainTextPassword, hashedPassword); return isOk; }; module.exports = { hash, compare }
Interactiuena cu tokenul JWT poate fi impartita in 2 categorii:
const jwt = require('jsonwebtoken'); //pachetul care se ocupa de tokenuri JWT const { ServerError } = require('../../errors'); const { getSecret } = require('docker-secret'); const generalOptions = { // optiunile ce tin de identitatea organizatiei care creaza un token issuer: 'Workshop Moby', subject: 'Authorization Token Workshop Moby', audience: 'Workshop Moby Users' }; const jwtKey = process.env.NODE_ENV === 'development' ? process.env.JWT_SECRET_KEY : getSecret(process.env.JWT_SECRET_KEY_FILE); // cheia este preluata din .env const generateToken = async (payload) => { // functia care se ocupa de generarea tokenului try { const encodingOptions = { ...generalOptions, expiresIn: process.env.JWT_EXPIRE_TIME || '1h' } // timpul de expirare este preluat din .env const token = await jwt.sign(payload.toPlainObject(), jwtKey, encodingOptions); // tokenul este obtinut apeland functia "sign" return token; } catch (err) { console.error(err); throw new ServerError("Error when encoding the token!", 500); } }; const verifyAndDecodeData = async (token, ignoreExpiration = false) => { // functia care se ocupa de extragerea datelor din token. O vom folosi in douua circumstante, intr-una fiind nevoie sa se tina cont de expirarea tokenului si in alta nu try { const decodingOptions = {...generalOptions, ignoreExpiration} const decoded = await jwt.verify(token, jwtKey, decodingOptions); // optiunile scoase sunt salvate in obiectul decoded return decoded; } catch (err) { // handling customizat de erori. Dorim sa intoarcem mesajul si codul clare. if (err.message === 'jwt expired') { throw new ServerError('Token is expired. Please refresh!', 401); } else if (err.message === 'jwt malformed') { throw new ServerError('Token is malformed!', 401); } throw new ServerError("Token is compromised!", 401); } }; module.exports = { generateToken, verifyAndDecodeData };
const { verifyAndDecodeData } = require('./token.js'); const { ServerError } = require('../../errors'); const verifyAuthHeader = (req) => { //verificarea existentei headerului "Authorization" if (!req.headers.authorization) { throw new ServerError('Authorization Header missing!', 401); } } const authorizeAndExtractToken = async (req, res, next) => { // middleware ce va fi atasat rutelor. Contine, in parametri, obiectele req, res si next try { verifyAuthHeader(req); const token = req.headers.authorization.split(" ")[1]; // extrage tokenul din header. Tokenul este separat prin spatiu de cuvantul Bearer const decoded = await verifyAndDecodeData(token); // preia obiectul decodat req.state = { // salveaza informatiile din token in req.state decoded }; next(); // prin apelul functiei next(), urmatorul middleware este notificat ca poate sa se execute } catch (err) { next(err); } }; const authorizeAndExtractTokenNoExpire = async (req, res, next) => { // acelasi lucru ca mai sus, doar ca se trimite in plus parametrul de ignoreExpiration = true la functia "verifyAndDecodeData" try { verifyAuthHeader(req); const token = req.headers.authorization.split(" ")[1]; const decoded = await verifyAndDecodeData(token, true); req.state = { decoded }; next(); } catch (err) { next(err); } }; module.exports = { authorizeAndExtractToken, authorizeAndExtractTokenNoExpire }
Clasa ce este folosita pentru impachetarea payload-ului JWT
class JwtPayload { constructor(id) { this.userId = id; } toPlainObject (){ // functia "sign" nu stie sa lucreze cu clase, ci cu obiecte json simple, asa ca trebuie returnat un obiect simplu return { userId: this.userId } } } module.exports = { JwtPayload }
Deoarece microserviciile sunt entitati/procese separate, ele comunica prin retea, nu in process. Pentru acest lucru, este nevoie de utilizarea unei forme de comunicare peste HTTP, sau event-based, prin cozi de mesaje. La acest workshop vom lucra exclusiv prin comunicare peste HTTP.
In NodeJS, comunicarea prin HTTP se poate face in multe moduri, insa cel mai uzual este prin folosirea pachetului axios. Mai mult, noi am scris un wrapper peste acest pachet care se ocupa si de error handling.
const axios = require('axios').default; const { ServerError } = require('../errors'); const sendRequest = async (options) => { // wrapper peste apelul de axios. Parametrul este un obiect care contine cel putin unu camp, url. Documentatia o gasiti pe pagina pachetului axios try { const { data } = await axios(options); // daca se realizeaza cu succes, este intors un obiect ce contine campul data unde se afla informatia efectiva return data; } catch (err) { if (err.isAxiosError) { // eroarea aruncata de axios are proprietatea specifica "isAxiosError" throw new ServerError(err.response.data.message, err.response.status); } throw err; } } module.exports = { sendRequest }
Filtrele JWT sunt atasate rutelor. Un exemplu de utilizare il puteti gasi in token/controllers.js. Un alt exemplu este in users/controllers.js
// users/controllers.js Router.delete('/', authorizeAndExtractToken, async (req, res) => { // observati cum filtrul JWT este atasat cererii const { userId } = req.state.decoded; await remove(userId); res.status(200).end(); });
In microserviciul Library am creat un nou filtru de autorizare.
Rolul acestui filtru este de a se conecta la microservicul de autorizare. Se trimite o cerere HTTP catre microserviciul de autorizare, prin care forwardeaza headerul ce contine tokenul JWT.
Microserviciul de autorizare despacheteaza tokenul si returneaza id-ul utilizatorului care a facut cererea si permisiunea acestuia in sistem.
Ruta din microserviciul de autorizare responsabila pentru autorizarea cererilor provenite de la alte microservicii
// auth-microservice/token/controllers.js Router.get('/authorizeAndReturnDetails', authorizeAndExtractToken, async (req, res) => { const { userId } = req.state.decoded; const role = await getRole(userId); res.json({ userId, role }); });
Filtrul din microserviciul library responsabil cu trimiterea cererilor de autorizare catre microserviciul de autorizare. Filtrul este o functie curry (functie ce returneaza alta functie).
const authorizeUser = (...roles) => { //poate accepta mai mult de un singur rol (de exemplu 'ADMIN', 'READER') return async (req, res, next) => { //returneaza un middleware clasic ce contine req, res, next if (!req.headers.authorization) { throw new ServerError("Authorization Header missing!", 403); } const options = { //creaza obiectul options ce va fi trimis clientului de http url: `http://${process.env.AUTH_SERVICE}/api/token/authorizeAndReturnDetails`, //IP-ul si PORT-ul serviciului de auth sunt preluate din .env. Observati ruta. headers: { 'Authorization': req.headers.authorization } } const { userId, role } = await sendRequest(options); if (!roles.includes(role)) { // se verifica daca rolul primit face parte din rolurile date ca parametru throw new ServerError(`${role} is not allowed for this method!`, 403); } req.state = { userId } next(); // se apeleaza next pentru a se trece la urmatorul middleware } }
Exemplu de utilizare in cadrul microserviciului Library:
const { ADMIN_ROLE, READER_ROLE } = process.env; //preluarea valorilor celor 2 roluri din .env Router.get('/:id', authorizeUser(ADMIN_ROLE, READER_ROLE), async (req, res) => { //introducerea filtrului pentru ambele roluri. Observati cum, de fapt, este pus un apel de functie. Motivul este datorat comportamentului de functie curry (functie in functie) pe care l-am folosit pentru a returna middleware-ul ce contine (req, res, next) const { id } = req.params; const author = await getById(id); res.json(author); });
Baza de date functioneaza la fel ca la microserviciul Library. Aveti un folder database-deploy in care veti efectua comanda
docker-compose up
Singurul exercitiu este sincronizarea dintre stergerea unui utilizator cu stergerea tuturor cartilor pe care acesta le-a introdus in sistem. Pentru acest lucru, trebuie sa urmariti TODO-urile puse in scheletul de cod.