L’autorizzazione lato frontend ti sembra troppo difficile? E’ arrivato il momento di rimediare.

FRONTEND DEVELOPMENTREACTANGULARRXJSMEAN STACKMERN STACK

Il flow di login è una parte fondamentale del lavoro di qualsiasi programmatore, frontend, backend e fullstack che sia. Oggi ci occupiamo principalmente di quello che concerne il programmatore frontend.

Chi ha una formazione puramente frontend a volte fa fatica a comprendere il processo di login e perché si sviluppa in un certo modo, scambiandolo -erroneamente- per una cosa complessa.

In realtà, una volta capita la logica, si tratta di un procedimento semplice.

E per capire la logica, potrebbe essere utile pucciare i piedini nel backend.

Nota: Per gli esempi in questo articolo, utilizzerò Javascript e Express.js. Non è necessario conoscere Express per seguire il ragionamento, ma ci sarà d’aiuto perchè rappresenta un “ponte” tra chi fa principalemente frontend (e quindi potrebbe non conoscere Java o .NET) e il backend. Javascript è un po’ la “lingua franca” in questo caso. Gli esempi saranno comunque attrezati da commenti esplicativi. Non voglio quindi che questo articolo sia un “tutorial” di Express e lascerò indietro tutto ciò che non è fondamentale alla spiegazione.

Autorizzazione e autenticazione: gemelle diverse

Molto spesso si parla di autorizzazione e autenticazione in modo intercambiabile, ma non penso esista errore più grande (forse solo chiamate HTML un linguaggio di programmazione 😅).

L’autorizzazione si occupa di identificare l’utente. Risponde alla domanda: “chi cavolo sei?”. A livello frontend, è quindi il cosidetto “login”.

L’autenticazione è invece il meccanismo che determina se un utente (già “loggato”) può o meno avere accesso ad una certa risorsa, pagina o endpoint. Risponde alla domanda “ma tu puoi entrare qui?”

Diversi tipi di autorizzazione

Basic

In passato, il web ha utilizzato molti tipi di autorizzazione, tra cui la relativamente nota Basic Authorization”. Nel flow Basic, le credenziali email e password vanno inviate e validate ad ogni richiesta. Queste informazioni non sono criptate in nessuna maniera, rendendo il nostro server particolarmente vulnerabile agli attacchi. Banalmente, basterebbe aprire la network tab e in qualsiasi richiesta fatta al server, potremmo trovare email e password. A livello backend richiede inoltre molte più letture del database, mentre il flow che utilizziamo adesso abitualmente, potrebbe richiederne 0.

Bearer

La più moderna autenticazione Bearer utilizza invece un token JWT. Questi token non contengono informazioni sensibili e perciò, se dovessero venir intercettati e decodificati, non farebbero alcun danno. Hanno inoltre una scadenza e un meccanismo di “difesa”, per cui un token che viene in qualche modo manipolato diventa automaticamente non valido. La creazione, verifica e decodifica di questi token viene gestita tramite backend.

Altri tipi di autorizzazione:

Va detto che ci sono molti altri metodi di autorizzazione, tra cui l’OAuth, che sembra andare molto per la maggiore recentemente (si tratta del diffusissimo “login con google”, o “login con facebook” che vediamo ogni giorno), ma oggi non andiamo a parlarne, in quanto non utilizza il sistema “a token” che ci interessa per questo articolo.

Cos’è un token JWT

Il token JWT (Json Web Token), è una lunga stringa contenente dei dati codificati (attenzione, NON criptati! Sono quindi facilmente ricostruibili). Questo token viene preso dal backend e “verificato”.

Se il vostro backend developer di fiducia ha fatto un lavoro “a modino”, il token non contiene dati sensibili (quindi NON contiene la password.)

Il token JWT è diviso in tre parti:

  • Header: nell’immagine, la parte in rosso. Sarà molto simile in tutti i token, in quanto contiene informazioni circa il tipo di algoritmo di codifica utilizzato.
  • Payload: nell’immagine, in viola. Questa è la parte “bollente” del vostro token: contiene tutte le informazioni necessarie, dal formato JSON ad una stringa codificata.
  • Signature: nell’immagine in azzurro: questa è la parte responsabile della verifica del token.

Ma quindi, Lidia, io posso prendere qualsiasi token, lanciarlo qui e scoprirne il contenuto?

Non mi sembra molto sicuro…

Beh, ni. Vedete l’ultimo campo in blu? I JWT token sono codificati in base ad una “password” segreta, comune a tutta l’applicazione. Se io codifico il mio segreto con “pippo” come password, non posso decodificarlo se come password inserisco “paperino”.

Un salto nel lato oscuro: come funziona il login nel backend

A livello backend, avremo una serie di endpoints con una struttura simile:

api.get("/", async (req, res, next) => {
// Creazione di un endpoint GET su path /
	res.send({message: "Hello World"}) 
});

Senza immergerci in database e ORM, un esempio di endpoint per il login potrebbe essere il seguente:

api.post("/login", async (req, res, next) => {
  // Creazione di un endpoint POST su path /login
  const { email, password } = req.body;
  const user = users.find((user) => user.email === email);
  //in un vero backend, qui useremmo una libreria per leggere il nostro db
  if (!user) req.status(401).send({ message: "Utente non trovato" });
  //Se l'utente non esiste, invia un errore 401
  if (user.password !== password) {
    res.status(401).send({ message: "Password errata" });
    //Se la password inviata e quella a db non corrispondono, invia un errore 401
    /*
            ATTENZIONE! In un vero backend, la password sarà criptata. Ci sarebbero quindi
            dei passaggi in più che non sto a riportare. 
    */
  }
  const token = JWT.createFrom({ data: user.email }, "15 min");
  //funzione *fittizia* che genera un token JWT a partire da dei dati
  //e ne imposta la durata a 15 minuti
  res.send({ user: user, token: token });
});

A questo punto, se l’utente inserisce le credenziali corrette, riceverà una response simile:

{
	user: {
		email: "lucanervi@email.com"
		name: "Luca Nervi",
		role: "Responsabile Acquisti" 
	}, 
	token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZGF0YSI6Imx1Y2FuZXJ2aUBlbWFpbC5jb20iLCJpYXQiOjE1MTYyMzkwMjJ9.I1LqmEP8IRF9t1STAsPIiYat3rB7PeflIuGPAQleVaM"
}

Da adesso e per i prossimi 15 minuti (dopodiché il token sarà scaduto e Luca dovrà rifare il login), ogni volta che Luca vorrà eseguire un’operazione, per identificarsi dovrà inviare il suo token come header della sua richiesta HTTPS (fetch o axios che sia).

Come viene ricevuta ed elaborata questa informazione dal backend?

Tramite una cosa chiamata middleware.

Middlewares

Una middleware non è altro che una funzione che si esegue tra la ricezione della richiesta e l’esecuzione del codice relativo all’endpoint (il cosiddetto controller).

Se utilizzare Angular o siete familiari con il concetto di interceptor, si tratta di un concetto molto simile.

Quindi se io invio una richiesta GET / (endpoint creato nel primo esempio), la middleware verrà eseguita PRIMA di entrare nella funzione asincrona (il controller), permettendoci di rifiutare l’accesso all’utente se necessario.

Mettiamo quindi caso che vogliamo un endpoint accessibile SOLO da Luca.

api.get("/luca", async (req, res, next) => {
// Creazione di un endpoint GET su path /
	res.send({message: "Dati segretissimi sui rimborsi spese di Luca"}) 
});

Come posso restringere l’accesso a tutti gli altri dipendenti?

Avrò bisogno di ricevere il token e di decifrarlo. Sappiamo che nel token è contenuta la email, per cui, una volta rivelato il contenuto del token, lo possiamo comparare con l’email di Luca.

In un esempio più concreto, questo check verrebbe fatto tramite il ruolo, anch’esso inviato nel token. Un utente con ruolo “admin” potrà accedere a tutto, mentre un utente con ruolo “dipendente” potrà accedere solo ad un certo tipo di risorse.

Nell’ordine, la nostra middleware per dare l’accesso solo a Luca farà le seguenti operazioni:

  1. Recupero del token
  2. Verifica e decodifica del token
  3. Invio un messaggio differenziato per il caso specifico oppure lascio passare l’utente.
const lucaOnly = (req,res,next) => {
		const token = req.headers["authorization"]
		const isValid = JWT.verify(token) //Verifico il token
		if(!isValid) res.send("Token scaduto o non valido")
		else {
			const {data} = JWT.decode(token) //Decodifico il token
			if(data === "lucanervi@email.com") { //Verifico l'identità dell'utente
				next() //passo alla prossima middleware o all'endpoint
			} else res.status(403).send("Non sei luca, quindi non sei autorizzato a entrare.")
		} 
}

Persi? Feriti? Dispersi? Guarda questo schemino:

La “ciccia”: come implementare il flow di login

Dalle poche nozioni di backend che abbiamo visto sopra, capiamo finalmente (spero 😛) perché il token è necessario ad ogni richiesta e come viene elaborato dall’API.

Vediamo come muoverci a livello frontend.

A prescindere dall’approccio, non abbiamo scelta: le nostre richieste HTTP andranno in qualche modo attrezzate con il nostro token JWT ottenuto dal login.

Perciò, alla sua ricezione, lo salviamo immediatamente nel localStorage.

A questo punto, possiamo decidere di prendere due approcci. Inserirlo manualmente negli headers ad ogni singola richiesta, con il rischio di dimenticarlo, oppure utilizzare un interceptor.

Interceptors: Angular, React e JS Vanilla

Lo so, lo so… Lidia, ma io gli interceptors non me li ricordo / non li ho capiti / sono difficili…

Mi spiace. Tocca impararli, sono utili. 😛

Il concetto di interceptor non è certo proprio di Angular, ma esiste anche in altre librerie.

Un interceptor è una funzione simile alla middleware: si antepone tra l’utente e la richiesta che intende effettuare aggiungendo una configurazione (i nostri headers) o agendo sulla risposta in modo “standardizzato” per tutte le risposte.

Interceptor in Angular:

@Injectable()
export class TokenInterceptor implements HttpInterceptor {

  constructor(private router: Router) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const clone = request.clone({
      headers: request.headers.set("authorization", `Bearer ${localStorage.getItem("token")!}`)
    })
    return next.handle(clone).pipe(catchError((error)=> {
      //Definite qui cosa succede in caso di errore: 
      //Solitamente, se si riceve un errore 401, quindi se il login è scaduto, si
      //reindirizza l'utente alla pagina di login. 
      return throwError(error)
    }));
  }
}

Interceptor in React o JS:

Se state lavorando con React oppure con JS vanilla, vi consiglio di dare un’occhiata ad axios.

axios fa le stesse cose che fa fetch, ma con funzionalità aggiuntive, tra cui gli interceptors.

const updateHeaderInterceptor = (axiosInstance: AxiosInstance) => {
  axiosInstance.interceptors.request.use((config) => {
    config.headers["Authorization"] =
      "Bearer " + localStorage.getItem("token");
    return config;
  });
};
const httpClient = axios.create();
updateHeaderInterceptor(httpClient);
export default httpClient;

La funzione updateHeaderInterceptor aggiunge a tutte le richieste HTTP fatte tramite axios un header contenente il nostro token.

Da questo momento in poi, invece di utilizzare axios.request(), utilizzeremo httpClient.requestin modo da allegare sempre il token in automatico.

Non male, vero? Un pensiero in meno da tenere a mente.

Come faccio a sapere se un utente è loggato o meno?

Finché il server non mi da un errore 401, possiamo stare tranquilli: il nostro token è ancora valido.

Nel momento in cui riceviamo un errore, il token è scaduto ed è quindi tempo di reindirizzare il nostro utente al login e fare le dovute modifiche all’UI.

E se uso JSON-SERVER(-AUTH)?

Puoi creare un file routes.json e configurare (limitatamente) quali endpoints sono accessibili da chi, vedi la documentazione .