facebook

Blog

Resta aggiornato

Autenticare un client Angular per accedere ad un’architettura a microservizi, usando OAuth e OpenID con Identity Server 4
Angular, Microservices e Autenticazione
mercoledì 01 Gennaio 2020

Sto lavorando su un nuovo progetto, per il quale ho bisogno di autenticare un client Angular per accedere a un set di microservizi. Il permesso di accedere a ogni microservizio dipende dai privilegi dell’utente corrente, ed ho bisogno di essere indipendente dal meccanismo di autenticazione.

Possiamo dividere il problema in due sottoproblemi principali: autenticazione e autorizzazione, utilizzando degli standard esistenti e scegliendo le giuste tecnologie per implementarli. Per gestire l’autenticazione degli utenti, possiamo usare OAuth e OpenID, che sono indipendenti dall’implementazione dell’authority. Per semplicità, partiamo con Microsoft Identity Server 4, un progetto open-source che implementa OAuth 2.0 e OpenID Connect per ASP.NET Core.

Partiamo con la creazione della struttura del progetto di esempio, con una cartella per il progetto Identity Server, una cartella per il client Angular e due cartelle per due generici microservizi:

Possiamo creare il client Angular con il solito comando della Angular CLI (ng new angular-client) e i due microservizi con il solito comando della .NET CLI (dotnet new webapi). Per creare il progetto Identity Server, invece, abbiamo bisogno di installare i template di Identity Serve 4:

dotnet new -i IdentityServer4.Templates

Questo comando installa vari template, da cui possiamo scegliere is4inmem che crea un progetto che salva in memoria tutti i dati di configurazione. Usando la memoria come storage, possiamo concentrarci sulle basi del framework senza introdurre la complessità di uno storage fisico (potete usare il template is4ef, se preferite salvare i dati di configurazione, usando Entity Framework).

Dobbiamo introdurre tre concetti per capire come funziona Identity Server:

  1. Identity Resource: sono informazioni come User ID, numero di telefono o indirizzo email che possono essere aggiunte all’identità dell’utente e incluse nel token utente.
  2. API Resource: definiscono le API che vogliamo proteggere
  3. Client: rappresentano i client come il nostro client Angular, che vogliono accedere alle risorse protette attraverso il sistema.

Possiamo trovare la configurazione di base del template nel file Config.cs. Salviamo queste informazioni in memoria per semplicità, ma possiamo salvarle in un database.

Dobbiamo adattare il template alle nostre esigenze. Come Identity Resource abbiamo bisogno solo della risorsa OpenID, che rappresenta l’ID univoco dell’utente, che nella specifica OpenID Connect è chiamato subject ID:

public static IEnumerable<IdentityResource> Ids =>
    new IdentityResource[]
    {
         new IdentityResources.OpenId()
    }; 

Come API, dobbiamo definire i nostri microservizi:

public static IEnumerable<ApiResource> Apis =>
    new ApiResource[]
    {
        new ApiResource("microservice1", "My Microservice #1"),
        new ApiResource("microservice2", "My Microservice #2")
    };

Il passaggio più importante è la definizione dei client. Nel nostro caso abbiamo bisogno della definizione di un solo client, ma possiamo definire tutti i client che vogliamo. Il nostro client Angular usa i dati che impostiamo per connettersi a Identity Server e ottenere il token per accedere alle risorse.

Tutti i client devono avere un identificativo univoco, chiamato ClientId, e opzionalmente possono avere un ClientName. Possiamo impostare l’URI da cui il client richiede la connessione, chiamato ClientUri, e una password per autenticarsi, chiamata ClientSecret. Possiamo scegliere se la secret è richiesta oppure no, in base al tipo di flow, chiamato Grant Type nella specifica OpenID Connect. Un Grand Type definisce come il client interagisce con il servizio che genera il token.

Nel nostro esempio, usiamo il Grant Type Implicit, che trasmette il token tramite il browser. Il flow Implicit è il più semplice tra quelli disponibili ed è molto usato con i client JavaScript, ma non permette l’uso di caratteristiche avanzate come il refresh del token. Puoi approfondire i Grant Type nella documentazione ufficiale.

Nel nostro esempio, scegliamo di non avere un ClientSecret (RequireClientSecret = false) perché un client JavaScript viene eseguito nel browser dell’utente, quindi è molto semplice recuperarlo dal codice. Dopo le operazioni di login e di logout, possiamo specificare una lista di URI tra cui il client può scegliere per reindirizzare l’utente. Possiamo anche abilitare le chiamate CORS ( https://developer.mozilla.org/it/docs/Web/HTTP/CORS ) dall’URI del client e permettere l’invio del token di accesso attraverso il browser (AllowAccessTokenViaBrowser = true). Per ultimare la configurazione, dobbiamo specificare per quali scopes il client richiede il token. Nel nostro caso, le info OpenID (l’identificatore del subject) e le API Resource definite:

public static IEnumerable<Client> Clients =>
    new Client[]
    {
         new Client
         {
             ClientId = "AngularClient",
             ClientName = "Angular Client",
             ClientUri = "http://localhost:4200",
             RequireClientSecret = false,
             RequireConsent = false,
             AllowedGrantTypes = GrantTypes.Implicit,
             RedirectUris = { "http://localhost:4200" },
             PostLogoutRedirectUris = { "http://localhost:4200/" },
             AllowedCorsOrigins = { "http://localhost:4200" },
             AllowAccessTokensViaBrowser = true,
             AccessTokenLifetime = 3600,
             AllowedScopes = { "openid", "microservice1", "microservice2" }
         } 
     };

Siamo pronti per definire i nostri account utente (anche qui usiamo la memoria come storage per semplificare il codice). Possiamo usare la classe TestUser fornita dal framework e impostata nel file TestUSers.cs (sotto la cartella QuickStart):

public static List<TestUser> Users = new List<TestUser>
{
     new TestUser{SubjectId = "1", Username = "michele", Password = "michele" },
     new TestUser{SubjectId = "2", Username = "antonio", Password = "antonio" }
};

È il momento di integrare il client Angular, con cui possiamo usare oidc-client ( https://github.com/IdentityModel/oidc-client-js ), una libreria pronta all’uso che ci aiuta a connetterci all’identity authority per recuperare il token dell’utente e accedere alle API protette:

npm i oidc-client

Per semplificare e astrarre le operazioni con questa libreria, possiamo creare un service Angular come questo:

import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserManager, User } from 'oidc-client';
 
@Injectable({providedIn: 'root'})
export class AuthService {
 
    public user: User;
    private userManager: UserManager;
 
    constructor() {
        this.userManager = new UserManager({
            authority: environment.authority,
            client_id: environment.clientId,
            redirect_uri: environment.redirectUri,
            response_type: environment.responseType,
            scope: environment.scope
        });
   }
 
     login() {
         return this.userManager.signinRedirect();
     }
 
     async completeAuthentication() {
         this.user = await this.userManager.signinRedirectCallback();
     }
 
     isAuthenticated(): boolean {
         return this.user != null && !this.user.expired;
     }
 
    signout() {
        this.userManager.signoutRedirect();
    }
}

La libreria ci fornisce due oggetti: lo UserManager per gestire le operazioni di login e logout e lo User per salvare le informazioni sul token. Il login viene fatto in due passaggi: un reindirizzamento alla pagina di login e il recupero del token dopo la redirezione. Il metodo login() esegue il primo passaggio, il completeAuthentication() esegue il secondo.

Possiamo usare la gestione degli environment dell’Angular CLI per configurare le nostre variabili di sviluppo:

export const environment = {
    production: false,
    authority: 'http://localhost:5000',
    clientId: 'AngularClient',
    redirectUri: 'http://localhost:4200',
    responseType: 'id_token token',
    scope: 'openid microservice1 microservice2',
    microservice1Url: 'http://localhost:5002/WeatherForecast',
    microservice2Url: 'http://localhost:5003/WeatherForecast'
};

Come possiamo vedere, impostiamo Authority con l’indirizzo dell’applicazione Identity Server, scegliamo AngularClient come clientId per connetterci al server e richiediamo un token (responseType: ‘id_token token’) con gli scopes OpenID, microservice e microservice2.

Adesso possiamo usare l’AuthService con il provider APP_INITIALIZER provider ( https://angular.io/api/core/APP_INITIALIZER ). Di solito, creo un AppService con un metodo specifico per fare tutto quello che mi serve allo startup di un client Angular. In questo caso, l’implementazione è molto semplice:

import { Injectable } from '@angular/core';
import { AuthService } from './auth.service';
 
@Injectable({providedIn: 'root'})
export class AppService {
 
    constructor(
        private authService: AuthService) { }
 
    initApp(): Promise<any> {
        return new Promise(async (resolve, reject) => {
            if (!this.authService.isAuthenticated()) {
                if (window.location.href.indexOf('id_token') >= 0) {
                   await this.authService.completeAuthentication();
                   resolve();
                } else if (window.location.href.indexOf('error') >= 0) {
                   reject();
               } else {
                  return this.authService.login();
              }
         }
     });
   }
}

Quando il client Angular parte, verifico se l’utente è già autenticato. Se non lo è, ho tre possibilità: l’utente non ha ancora cominciato la fase di autenticazione, sto tornando dal reindirizzamento del login con successo o sto tornando dal reindirizzamento del login con un errore. Posso capire dall’URL qual è lo stato corrente:

  • Se l’URL contiene la stringa ‘id_token’, sto tornando dal login con successo, e posso completare l’autenticazione chiamando il metodo completeAuthentication();
  • Se l’URL contiene la stringa ‘error’ il login è stato completato con un errore;
  • Se l’URL non contiene nulla posso chiamare il metodo login() per reindirizzare l’utente alla pagina di login.

Siamo pronti a usare il token recuperato per chiamare i microservizi dell’applicazione. Il modo migliore per farlo in Angular è un interceptor per aggiungere il token su tutte le richieste:

import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
 
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
 
    constructor(
        private authService: AuthService) {}
 
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any > > {
        const headers = req.headers
           .set('Content-Type', 'application/json')
           .set('Authorization', `${this.authService.user.token_type}  ${this.authService.user.access_token}`);
        const authReq = req.clone({ headers });
        return next.handle(authReq);
     }
}

Usiamo il token di accesso (che è un semplice JSON Web Token o più brevemente un JWT Token) nell’header Authorization. Il token_type, nel nostro caso, è il classico ‘Bearer’.

Adesso possiamo configurare il tutto nell’ AppModule:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
 
import { AppComponent } from './app.component';
import { AppService } from './app.service';
import { AuthInterceptor } from './auth.interceptor';
 
export function initApp(appService: AppService) {
 return () => appService.initApp();
}
 
@NgModule({
 declarations: [ AppComponent ],
 imports: [ BrowserModule, HttpClientModule ],
 providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
    { provide: APP_INITIALIZER, useFactory: initApp, deps: [AppService], multi: true }
 ],
 bootstrap: [AppComponent]
})
export class AppModule { }

L’AppComponent è semplicemente una lista di chiamate a ogni microservizi e un pulsante di sign out.

<h2>Angular Client </h2>
<h3>Available Microservices: </h3>
<ul>
 <li >Microservice 1: <button type="button" (click)="callMicroservice1()">Call</button > </li>
 <li >Microservice 2: <button type="button" (click)="callMicroservice2()">Call</button > </li>
</ul>
<button type="button" (click)="signout()">Signout </button>

Questo è il codice dell’AppComponent:

import { Component, OnInit } from '@angular/core';
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
import { AuthService } from './auth.service';
 
@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 providers: [Location, {provide: LocationStrategy, useClass: PathLocationStrategy}],
})
export class AppComponent {
    constructor(
        location: Location,
        private authService: AuthService,
        private httpClient: HttpClient) {
        location.replaceState('/');
     }
 
    callMicroservice1() {
        this.httpClient.get(environment.microservice1Url)
           .subscribe(
               data => alert('SUCCESS: ' + JSON.stringify(data)),
               error => alert('ERROR: ' + JSON.stringify(error)));
    }
 
    callMicroservice2() {
        this.httpClient.get(environment.microservice2Url)
           .subscribe(
               data => alert('SUCCESS: ' + JSON.stringify(data)),
               error => alert('ERROR: ' + JSON.stringify(error)));
    }
 
    signout() {
        this.authService.signout();
    }
}

Notate l’uso del location service: riceviamo le informazioni del token tramite l’URL, quindi abbiamo bisogno di cancellare questi dati e rimuovere la chiamata dalla storia del browser. Il metodo location.replaceState() sostituisce l’elemento più in alto nello stack della storia del browser e normalizza l’URL fornito.

Siamo pronti per la configurazione dei microservizi, dove dobbiamo configurare il processo di autenticazione usando JWT. Dobbiamo installare il pacchetto Microsoft.AspNetCore.Authentication.JwtBearer e aggiungere al metodo ConfigureService() della classe Startup il seguente codice:

services.AddAuthentication(options =>
 {
     options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
     options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
 }).AddJwtBearer(o =>
 {
     o.Authority = "http://localhost:5000";
     o.Audience = "microservice1";
     o.RequireHttpsMetadata = false;
});
 
services.AddAuthorization();

Nel metodo Configure() dobbiamo aggiungere il middleware UseAuthentication per abilitare il processo di autenticazione:

app.UseAuthentication();

Il template ‘webapi’ crea una API WeatherForecast con alcuni dati di esempio che possiamo proteggere utilizzando l’attributo Authorize:

[ApiController]
[Route("[controller]")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
    ...
}

Per semplificare il debug, possiamo impostare il microservizio1 per rispondere sulla porta 5002 e il microservizio2 per rispondere sulla porta 5003. Per fare questo, possiamo cambiare il launchSettings.json nella cartella Properties per ogni microservizio:

"microservice1": {
     "commandName": "Project",
     "launchBrowser": true,
     "launchUrl": "weatherforecast",
     "applicationUrl": "http://localhost:5002",
     "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
     }
 }

Possiamo eseguire le nostre quattro applicazioni e aprire il browser all’indirizzo localhost:4200. Se tutto gira correttamente, il client ci re-indirizzerà verso localhost:5000 per la fase di login, e dopo il login Identity Server ci re-indirizzerà verso localhost:4200 con il token_id nell’URL.

Se volete vedere la richiesta con il token recuperato, potete usare il pannello Network dei developer tool di Chrome:

Ottimo! Ma voglio aggiungere almeno un’altra feature: abilitare l’accesso ad ogni microservizio in base all’utente corrente. Abbiamo due utenti, Michele e Antonio, e adesso voglio impedire l’accesso al microservizio2 per Antonio. Posso farlo in modi differenti, ma preferisco l’uso dei Claims utente perché abilita scenari avanzati nella granularità degli accessi.

Nella classe TestUsers aggiungiamo alcuni claims agli utenti:

public static List<TestUser> Users = new List<TestUser>
{
    new TestUser { SubjectId = "1", Username = "michele", Password = "michele", 
        Claims = 
        {
            new Claim("microservice1", "admin"),
            new Claim("microservice2", "admin"),
        }
    },
    new TestUser { SubjectId = "2", Username = "antonio", Password = "antonio", 
        Claims = 
        {
            new Claim("microservice1", "user"),
        }
     }
 };

Come possiamo vedere, Michele ha due claims, microsevice1 e microservice2, mentre Antonio ne ha solo uno, microservice1. Abbiamo bisogno che questi claims vengano aggiunti al token di accesso, ma Identity Server non lo fa per noi. Possiamo eseguire del codice personalizzato durante la creazione del token implementando un ProfileService personalizzato a partire dall’interfaccia IProfileService. L’interfaccia IProfileService richiede l’implementazione di due metodi: il GetProfileDataAsync, chiamato durante la creazione del token per recuperare il profilo dell’utente, e il metodo IsActiveAsync, che permette di personalizzare il criterio per il quale l’utente è attivo oppure no. Il metodo GetProfileDataAsync ci fornisce il ProfileDataRequestContext che espone la lista IssuedClaims per l’aggiunta di claims addizionali al profilo:

using System.Linq;
using System.Threading.Tasks;
using IdentityServer4.Models;
using IdentityServer4.Quickstart.UI;
using IdentityServer4.Services;
 
namespace identityserver.Services
{
    public class ProfileService : IProfileService
    {
         public Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
             string sub = context.Subject.FindFirst("sub").Value;
             var user = TestUsers.Users.FirstOrDefault(x => x.SubjectId == sub);
             foreach (var claim in user.Claims)
            {
                 context.IssuedClaims.Add(claim);
            }
           return Task.CompletedTask;
       }
 
       public Task IsActiveAsync(IsActiveContext context)
       {
            context.IsActive = true;
            return Task.CompletedTask;
       }
   }
}

Adesso possiamo aggiungere il profile service personalizzato nel ConfigureService() del progetto Identity Server:

builder.AddProfileService<ProfileService> ();

Grazie a queste modifiche, l’access token dell’utente corrente contiene i claims aggiunti, che possiamo utilizzare per discriminare l’accesso ai nostri microservizi. Dobbiamo tornare al ConfigureService() dei microservizi e cambiare l’AddAuthorization aggiungendo una Authorization Policy:

services.AddAuthorization(options = >
{
    options.AddPolicy("ApiUser", policy => policy.RequireClaim("microservice1"));
}); 

Le Authorization Policies sono un nuovo meccanismo introdotto in .NET Core per controllare l’accesso alle API tramite delle regole personalizzate. Nel nostro caso, definiamo una nuova policy chiamata “ApiUser” che verifica la presenza del claim ‘microservice1’ nel profilo dell’utente. Adesso possiamo modificare l’attributo Authorize del controller WeatherForecast per applicare la nuova policy:

[ApiController]
[Route("[controller]")]
[Authorize("ApiUser")]
public class WeatherForecastController : ControllerBase
{
    ...
} 

Dopo aver fatto le stesse operazioni per il microservizio2, possiamo eseguire l’applicazione e verificare se Antonio può accedere al microservizio2:

Come potete immaginare, possiamo implementare scenari molto sofisticati usando OAuth, OpenID e le Authorization policies di .NET Core. Identity Server può aiutarci a provare differenti opzioni e scegliere il flow corretto per la nostra applicazione. Tutto il codice è disponibile qui: https://github.com/apomic80/angular-microservices-identityserver .

Happy Coding!