L'uscita di ASP.NET Core – nell'estate del 2016 – si è accompagnata da un sistema di autenticazione/autorizzazione tutto nuovo e basato, come il resto del framework, sul concetto di middleware.
Il middleware è un pezzo di codice che si innesta nella pipeline di ASP.NET con lo scopo di occuparsi di una specifica attività nell'ambito della gestione di una richiesta HTTP. In questo caso, il middleware è responsabile di fare in modo che il richiedente sia autorizzato ad effettuare la richiesta, cioè ad accedere alla relativa risorse. Se il concetto non vi è del tutto chiaro, potete approfondirlo leggendo le nozioni fondamentali di middleware in ASP.NET Core.
Come era forse lecito attenderci, il nuovo sistema si è dimostrato un po' immaturo alla prova dei fatti: in particolare il meccanismo di integrazione tra i diversi middleware di autenticazione era un po' complesso e poteva portare a situazioni incoerenti in determinati scenari.
Il Team di ASP.NET ha quindi deciso di rivedere (nuovamente) il sistema dalle fondamenta e la versione aggiornata è uscita contemporaneamente ad ASP.NET Core 2.
In questa serie di post andremo alla scoperta dei suoi meccanismi di funzionamento, concentrandoci su uno scenario ben definito, quello dell'autenticazione/autorizzazione attraverso Azure Active Directory (in seguito AAD) usando il protocollo OpenId Connect (OIDC).
La maggior parte dei post che trattano questo argomento iniziano immancabilmente con una tediosa spiegazione della differenza tra autenticazione ed autorizzazione. Io mi limito a dire che l'autenticazione è il processo di determinazione dell'identità dell'utente, mentre l'autorizzazione definisce quali siano i suoi permessi dopo che l'identità è stata accertata. Basta. Se volete masochisticamente sviscerare ulteriormente la questione, trovate tonnellate di post in materia.
L'approccio che cercherò di tenere, ove possibile, è di partire dal codice e di introdurre i concetti teorici nel corso della trattazione. Io userò Visual Studio 2017 (versione 15.7, ma Visual Studio Code dovrebbe essere ugualmente adatto) e .NET Core SDK 2.1.200 (per sapere quale versione state utilizzando è sufficiente digitare dotnet --version dal prompt dei comandi).
Per tener conto di chi utilizza IDE diversi da Visual Studio, partiremo creando lo scheletro del progetto a riga di comando usando la CLI dotnet. Dopo esserci posizionati nella cartella in cui vogliamo creare il progetto, i comandi da lanciare sono i seguenti:
- dotnet new sln --name LearningAuth (se pensiamo di usare Visual Studio e vogliamo creare il file di solution);
- dotnet new web --name LearningAuth.WebApp (per creare un'applicazione web vuota)
- dotnet sln add .\LearningAuth.WebApp\LearningAuth.WebApp.csproj (sempre se usiamo Visual Studio, per aggiungere l'applicazione alla solution)
Non mi addentro nei dettagli del progetto creato, perchè la documentazione Microsoft sui concetti fondamentali di ASP.NET Core è decisamente esaustiva.
Il progetto così come lo abbiamo creato si limita a mostrare a video la scritta "Hello world!" indipendentemente da quale sia la risorsa richiesta (ossia la URL digitata) e ovviamente non ha alcuna nozione di risorsa protetta da autorizzazione. Per introdurre il concetto, creiamo un nuovo semplice controller, la relativa view e configuriamo l'applicazione in modo che sia in grado di servirla al client.
Iniziamo creando una cartella Controllers nella root del progetto e aggiungendo un nuovo Controller (che chiameremo HomeController) al suo interno. Il controller avrà per ora un solo metodo Index che dovremo decorare con l'attributo Authorize:
Per creare la view clicchiamo con il destro all'interno del metodo e scegliamo Add View... dal menu contestuale:
Conserviamo le impostazioni di default suggerite da Visual Studio e clicchiamo su Add:
La view di risultante sarà estremamente scarna ma per ora ampiamente sufficiente allo scopo. Ci limiteremo ad aggiungere il codice @User.Identity.Name
per stampare il nome dell'utente autenticato:
L'unico problema è che la view si basa una pagina di layout che attualmente non è presente nel nostro progetto. Creiamo allora una nuova cartella Shared sotto Views e aggiungiamo al suo interno una MVC View Layout Page:
Potremmo impostare la pagina di layout direttamente dentro la view, ma è più comodo aggiungere invece una MVC View Start Page e una MVC View Imports Page direttamente sotto la cartella Views. Potranno servirci anche in futuro.
Infine la configurazione: a differenza di quanto avveniva in ASP.NET Core 1, nella versione 2 è stato definito il nuovo metapackage Microsoft.AspNetCore.All che evita la necessità di includere singolarmente la pletora di pacchetti che compongono ASP.NET Core ed EntityFramework Core. Per tale motivo possiamo configurare direttamente il middleware di MVC senza dover manualmente aggiungere il package relativo.
Apriamo quindi il file Startup.cs e configuriamo il middleware di MVC. Per prima cosa dobbiamo registrare i servizi necessari ad MVC nello IoC container che è parte dell'architettura di Dependency Injection nativa di ASP.NET Core. Possiamo farlo aggiungendo il codice relativo nel metodo ConfigureServices. Quindi possiamo aggiungere il middleware MVC alla pipeline nel metodo Configure (che a mio modesto parere sarebbe stato meglio si chiamasse ConfigurePipeline per togliere ogni dubbio, ma tant'è). Il risultato finale deve essere questo:
Per concludere, è opportuno già da subito passare dal protocollo HTTP a quello HTTPS per garantire che le comunicazioni avvengano in forma crittografata in modo tale da prevenire il "tampering" dei dati. Apriamo le proprietà del progetto e nella scheda Debug selezioniamo la casella Enable SSL. Copiamo quindi l'indirizzo (tenetelo a mente perchè ci servirà anche in seguito) e incolliamolo come App URL:
Se preferite potete modificare direttamente il file launchSettings.json impostando le chiavi iisSettings:issExpress:applicationUrl e iisSettings:issExpress:sslPort):
Proviamo a lanciare nuovamente la nostra applicazione è il risultato sarà il seguente:
Il messaggio è forse un po' criptico ma in definitiva ci fornisce due informazioni interessanti: ci dice che l'Authentication Scheme non è stato trovato, e ci dice anche che l'errore si è verificato in un metodo chiamato ChallengeAsync.
Ma cos'è un authentication scheme? È un identificativo univoco del metodo di autenticazione che vogliamo usare nella nostra applicazione, o in una parte di essa. Anche se non è un caso molto frequente, dobbiamo considerare che potrebbe essere necessario gestire più di un tipo di autenticazione all'interno della stessa applicazione (ad esempio distinguendo i controller MVC per i quali vogliamo impostare un'autenticazione basata su cookie da quelli WebAPI per i quali vogliamo invece usare dei token JWT). Per consentire ad ASP.NET di gestire tali scenari, ogni metodo di autenticazione viene registrato tra i servizi con uno specifico identificativo univoco (che altri non è se non un nome in formato stringa).
Quando aggiungiamo un attributo authorize nel codice, possiamo indicare quale metodi di autorizzazione (tra quelli registrati) vogliamo usare per quel particolare controller o action, specificando il relativo schema. Se non lo facciamo (come nel nostro caso), il middleware userà lo schema di default. In questo caso non abbiamo definito neanche quello, da cui l'eccezione che abbiamo riscontrato.
Il problema deriva dal fatto che abbiamo sì richiesto che la nostra action fosse protetta da autorizzazione, ma non abbiamo configurato quale metodo di autenticazione vogliamo usare.
Come detto in apertura, siamo interessati ad esplorare lo scenario dell'autenticazione con Azure Active Directory usando il protocollo OpenId Connect. Sarebbe ora il momento di approfondire i fondamenti teorici di OpenId Connect e del middleware di autenticazione. Ma, visto che il manifesto Agile ci dice che è più importante un "software funzionante" di una "documentazione esaustiva" (sperando che Felice non ci legga) useremo invece un approccio mistico: arriviamo fideisticamente ad un applicazione funzionante e vi prometto poi di tornare indietro per dare un senso alle azioni intraprese.
Cominciamo abbandonando momentaneamente la nostra applicazione per spostarci sul portale di registrazione delle applicazioni di Microsoft. Visto che vogliamo usare AAD, dobbiamo prima "farci conoscere" per instaurare quel rapporto di "trust" tra la nostra applicazione e AAD che ci consentirà di delegargli la parte di autenticazione.
Registriamo quindi la nostra applicazione sul portale dopo aver fatto login con la nostra utenza, cliccando su Aggiungi un'app (non mi chiedete perché alcune pagine sono in italiano e altre in inglese):
Inseriamo il nome della nostra applicazione è clicchiamo su Create (ci sarebbe anche la possibilità di effettuare un setup guidato selezionando la casella relativa, ma per applicazioni ASP.NET Core tale opzione si risolve in un link ad un esempio di configurazione):
Dopo qualche istante veniamo indirizzati alla pagina con le proprietà della nostra applicazione appena registrata. Qui dobbiamo specificare che si tratta di un'applicazione web aggiungendo una nuova piattaforma nella sezione relativa:
Aggiungiamo una piattaforma Web e impostiamo quindi la URL di reindirizzamento, mantenendo la casella Consenti flusso implicito selezionata e ignorando per ora la URL di disconnessione:
La URL va chiaramente impostata in accordo con quella della nostra applicazione (https://localhost:44303/ nel mio caso) aggiungendo l'endpoint signin-oidc (vedremo in seguito di che si tratta).
Possiamo ora salvare le modifiche ma, prima di uscire, annotiamoci l'ID applicazione che si trova in alto sotto al nome:
Ora che Azure Active Directory "conosce" la nostra applicazione, possiamo configurarla per gestire l'autenticazione/autorizzazione in nostra vece per mezzo del protocollo OpenId Connect. Fortunatamente Microsoft ci mette a disposizione un middleware (in ASP.NET Core 2 questa definizione è leggermente scorretta ma passatemi l'imprecisione per evitare ulteriore complessità) che si prende carico di tutte le cerimonie di comunicazione tra la nostra applicazione e un generico provider OIDC.
La prima cosa da fare è, al solito, registrare i servizi nello IoC container:
Questo codice aggiunge i servizi generici del middleware di autenticazione e poi quelli specifici di OIDC. Ricorderete che il messaggio di errore che abbiamo ricevuto in precedenza parlava della mancata definizione di un DefaultChallengeScheme. Per risolvere il problema impostiamo tale schema pari allo schema di default di OIDC (cioè la stringa OpenIdConnect).
Ovviamente, per quanto intelligente possa essere, il middleware non è in grado di sapere con quale provider vogliamo comunicare. È quindi necessario un minimo di configurazione che può essere fatta direttamente nel codice o, come in questo caso, delegata al file di configurazione. Il modo più semplice di farlo è sfruttare il metodo di estensione Bind dell'interfaccia IConfigurationSection. È sufficiente che la struttura della classe di opzioni che vogliamo riempire corrisponda alla struttura della particolare sezione del file di configurazione. Nel nostro caso le chiavi presenti nella sezione OpenIdConnect saranno usate per valorizzare l'oggetto options di classe OpenIdConnectOptions:
L'Authority indica il tenant AAD da interrogare ed è composto dalla URL https://login.microsoftonline.com più l'identificativo del nostro tenant. Come identificativo possiamo usare il nome, ma l'ID è preferibile data la sua immutabilità certa nel tempo. Nel recuperare l'ID del nostro tenant AAD, possiamo aprire il portale di Azure e navigare in Azure Active Directory > Proprietà: ID Directory è il nostro uomo:
Se il vostro account ha accesso a più tenant, ricordatevi di verificare di essere collegati alla directory giusta verificando in altro a destra del portale:
Sistemata l'authority possiamo passare al ClientId: altro non è l'ID Applicazione che abbiamo annotato in fase di registrazione sul portale.
L'ultimo passo è aggiungere il middleware di autenticazione alla pipeline, ovviamente prima che MVC abbia modo di intervenire per gestire la richiesta:
Tutto sembra essere a posto, possiamo provare a lanciare nuovamente la nostra applicazione. Se tutto va bene dovremmo essere reindirizzati alla pagina di login di AAD:
E quindi, dopo essersi autenticati, nuovamente alla nostra applicazione... dove però ci attende una sorpresa:
La prima cosa che notiamo è che AAD ha effettivamente effettuato un redirect alla pagina https://localhost:44303/signin-oidc. Nonostante tale pagina non sia presente nella nostra applicazione, in qualche modo ASP.NET è riuscito a gestirla: vedremo prossimamente come e perchè.
La seconda è che il metodo SignInAsync è andato in eccezione per la mancanza dello schema DefaultSignInScheme, il che ci lascia particolarmente perplessi visto che il login è stato gestito da AAD e che sembrerebbe essere andato a buon fine. Che fa quindi il metodo SignInAsync? E che vuol dire quel messaggio? Lo scopriremo nel prossimo post, per ora limitiamoci a modificare leggermente il codice in Startup.cs in modo da farlo funzionare:
Stavolta ci siamo:
Happy coding!
Il codice di questo post è disponibile su GitHub.