blexin

Sviluppo
Consulenza e
Formazione IT


Blog

Evolvi la tua azienda

Vediamo insieme come gestire l’autenticazione in una architettura a microservizi con ASP.NET Core, Angular, IdentityServer, MongoDB e Docker

Angular, Microservizi e Autenticazione con IdentityServer, MongoDB e Docker

Mercoledì 20 Maggio 2020

In un mio articolo precedente , abbiamo parlato di microservizi e come autenticare un client Angular con essi usando IdentityServer come authentication authority. In quel caso, ho usato la configurazione in memoria per semplificare il concetto, ma in un'applicazione reale abbiamo bisogno di salvare i dati in uno storage persistente come un database.

Visto che in un progetto ho usato MongoDB per alcuni microservizi, ho pensato fosse utile usare l’istanza di Mongo per salvare anche i dati di IdentityServer, ovviamente in un database separato, e la cosa ha avuto dei risvolti interessanti che vale la pena raccontare. Inoltre, in uno scenario di sviluppo reale, non avrete mai tutti i microservizi e i client in uno stesso repository, quindi, lavorare in localhost può diventare un problema, che però è facilmente risolvibile utilizzando docker e docker-compose. Anche questo aspetto ha dei risvolti interessanti perché vi mette di fronte ad alcune considerazioni di networking che in localhost sfuggono sicuramente.

IdentityServer e MongoDB

Cominciamo dall’integrazione di MongoDB con IdentityServer. Esistono alcune librerie pronte all'uso per questo scopo, ma le migliori utilizzano entity framework, come layer intermedio per archiviare dati su MongoDB. Non capisco il motivo dell'uso di un ORM con un database NoSql, penso che non abbia senso, ma ho accettato l’idea che si tratti di una comodità per poter cambiare storage quando riutilizzo qualcosa in un progetto differente, perchè diciamocelo… cambiare database in un progetto in produzione è una favola che viene raccontata ai bambini e ai giovani programmatori.

Ho quindi utilizzato una mia implementazione generica del Repository pattern per MongoDB:

public interface IRepository
{
    IQueryable<T> All<T>() where T : class, new();
    IQueryable<T> Where<T>(Expression<Func<T, bool>> expression) where T : class, new();
    T Single<T>(Expression<Func<T, bool>> expression) where T : class, new();
    void Delete<T>(Expression<Func<T, bool>> expression) where T : class, new();
    void Add<T>(T item) where T : class, new();
    void Add<T>(IEnumerable<T> items) where T : class, new();
}

public class MongoRepository : IRepository
{
    private readonly IMongoClient _client;
    private readonly IMongoDatabase _database;
 
    public MongoRepository(string mongoConnection, string mongoDatabaseName)
    {
        _client = new MongoClient(mongoConnection);
        _database = _client.GetDatabase(mongoDatabaseName);
    }
 
    public IQueryable<T> All<T>() where T : class, new()
        => _database.GetCollection<T>(typeof(T).Name).AsQueryable();
 
    public IQueryable<T> Where<T>(System.Linq.Expressions.Expression<Func<T, bool>> expression) where T : class, new()
        => All<T>().Where(expression);
 
    public void Delete<T>(System.Linq.Expressions.Expression<Func<T, bool> predicate) where T : class, new()
        => _database.GetCollection<T>(typeof(T).Name).DeleteMany(predicate);
 
    public T Single<T>(System.Linq.Expressions.Expression<Func<T, bool>> expression) where T : class, new()
        => All<T>().Where(expression).SingleOrDefault();
 
    public void Add<T>(T item) where T : class, new()
        => _database.GetCollection<T>(typeof(T).Name).InsertOne(item);
 
    public void Add<T>(IEnumerable<T> items) where T : class, new()
        => _database.GetCollection<T>(typeof(T).Name).InsertMany(items);
}

Niente di complicato, è un semplice utilizzo del driver C# ufficiale per MongoDB per implementare le operazioni CRUD del repository. Per poter usare il repository con IdentityServer, dobbiamo implementare le interfacce IResourceStore, IPersistedGrantStore e IClientStore con cui IdentityServer recupera le Resource, i Grant e i Client da utilizzare nel processo di autenticazione e generazione del token. A titolo di esempio, vi riporto l’implementazione di IClientStore, le restanti classi le trovate sul mio repository GitHub:

public class RepositoryClientStore : IClientStore
{
    protected IRepository _repository;
 
    public RepositoryClientStore(IRepository repository)
        => _repository = repository;
 
    public Task<Client> FindClientByIdAsync(string clientId)
        => Task.FromResult(_repository.Single<Client>(c => c.ClientId == clientId));
}

Creiamo una classe di helpers per registrare tutte queste interfacce in modo da rendere più fluente la configurazione dei servizi:

public static class RepositoryExtensions
{
    public static IIdentityServerBuilder AddMongoRepository(
        this IIdentityServerBuilder builder,
        string mongoConnection,
        string mongoDatabaseName)
    {
        builder.Services.AddTransient<IRepository, MongoRepository>(
            s => new MongoRepository(mongoConnection, mongoDatabaseName));
        return builder;
    }
 
    public static IIdentityServerBuilder AddClients(this IIdentityServerBuilder builder)
    {
        builder.Services.AddTransient<IClientStore, RepositoryClientStore>();
        builder.Services.AddTransient<ICorsPolicyService, InMemoryCorsPolicyService>();
        return builder;
    }
 
    public static IIdentityServerBuilder AddIdentityApiResources(this IIdentityServerBuilder builder)
    {
        builder.Services.AddTransient<IResourceStore, RepositoryResourceStore>();
        return builder;
    }
 
    public static IIdentityServerBuilder AddPersistedGrants(this IIdentityServerBuilder builder)
    {
        builder.Services.AddSingleton<IPersistedGrantStore, RepositoryPersistedGrantStore>();
        return builder;
    }       
}

A questo punto la configurazione diventa banale:

public void ConfigureServices(IServiceCollection services)
{
    ...
    var builder = services.AddIdentityServer(options =>
    {
        options.Events.RaiseErrorEvents = true;
        options.Events.RaiseInformationEvents = true;
        options.Events.RaiseFailureEvents = true;
        options.Events.RaiseSuccessEvents = true;
    })
    .AddMongoRepository(
        Configuration.GetValue<string>("MONGO_CONNECTION"),
        Configuration.GetValue<string>("MONGO_DATABASE_NAME"))
    .AddClients()
    .AddIdentityApiResources()
    .AddPersistedGrants();
 
    seedDatabase(services);
    ...
}

Ho anche aggiunto un metodo privato seedDatabase() che inserisce nel database i dati che usavamo in memoria se rileva che le rispettive collection sono vuote:

private void seedDatabase(IServiceCollection services)
{
    configureMongoDriverIgnoreExtraElements();
    var sp = services.BuildServiceProvider();
    var repository = sp.GetService<IRepository>();
 
    if (repository.All<Client>().Count() == 0)
    {
        foreach (var client in Config.Clients)
        {
            repository.Add<Client>(client);
        }
    }
    ...
}

Dato che andiamo a salvare su Mongo degli oggetti non nostri, ma di IdentityServer, dobbiamo istruire MongoDB sul fatto che non troverà in queste classi alcuni elementi extra che vengono aggiunti dal motore del database durante la creazione dei documenti, come ad esempio la proprietà _id. Per farlo, ho creato un metodo privato che chiamo prima dell’eventuale inserimento dei dati, configureMongoDriverIgnoreExtraElements():

private void configureMongoDriverIgnoreExtraElements()
{
    var pack = new ConventionPack();
    pack.Add(new IgnoreExtraElementsConvention(true));
    ConventionRegistry.Register("IdentityServer Mongo Conventions", pack, t => true);
}

In teoria abbiamo terminato, andiamo adesso a testare la nostra configurazione.

Docker e Docker Compose

Visto che dobbiamo tirare su il demone di MongoDB, approfittiamo per cominciare a configurare il nostro docker-compose, dove al momento mettiamo solo MongoDB. Se siete a digiuno di Docker potete leggere un mio precedente articolo oppure il mio libro gratuito che parla anche di Kubernetes (https://www.syncfusion.com/ebooks/using-netcore-docker-and-kubernetes-succinctly).

Ė possibile creare manualmente il file docker-compose.yml nella root del progetto, oppure utilizzare i tool di Docker per Visual Studio Code:

Modifichiamo lo script per tirare su il container con MongoDB e esponiamo la porta su localhost. In generale, non è una bella cosa lasciare che Mongo salvi i dati all’interno del container, ma per i nostri esempi e limitatamente alla fase di sviluppo, possiamo evitare di creare un volume per essi:

version: '3.4'
services:
 mongodb:
   image: mongo:latest
   hostname: mongodb
   ports:
     - "27017:27017"

A questo punto possiamo eseguire lo script (docker-compose up) e una volta su, possiamo lanciare il progetto IdentityServer con il classico dotnet run. Se tutto va a buon fine vedrete le collection del database Mongo:

Creiamo adesso un Dockerfile per poterlo containerizzare il nostro IdentityServer. Nella folder del progetto identityserver, creiamo un file Dockerfile che conterrà le seguenti istruzioni:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /app
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o out
 
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS runtime
WORKDIR /app
COPY --from=build /app/out ./
ENTRYPOINT ["dotnet", "identityserver.dll"]

Aggiungiamo anche un .Dockerignore per escludere dalla copia dei file nel container le cartelle bin e obj:

/bin
/obj

Aggiungiamo la build del Dockerfile al nostro script docker-compose.yml in modo da avere tutto pronto per il client e i microservizi:

version: '3.4'
services:
 identityserver:
   build: ./identityserver
   environment:
     - MONGO_CONNECTION=mongodb://mongodb
     - MONGO_DATABASE_NAME=identityserber
   ports:
     - "5000:80"
   depends_on:
     - mongodb
 
 mongodb:
   image: mongo:latest
   hostname: mongodb
   ports:
     - "27017:27017"

In realtà, non c’è più bisogno di esporre la porta di MongoDB su localhost, dato che il container identityserver si connetterà a Mongo utilizzando la network di default creata da Docker e risolverà l’indirizzo del database attraverso il nome del service (mongodb). Nonostante questo, ho lasciato il forwarding della porta per comodità, in modo da potermi sempre collegare con un client come Robo 3T , per ispezionare le collection. Lanciando il comando docker-compose up, viene prima creata l’immagine per IdentityServer (solo la prima volta) e poi lanciati entrambi i container, quindi aprendo il browser all’indirizzo http://localhost:5000/account/login vedremo IdentityServer risponderci correttamente:

A questo punto, possiamo lanciare il client Angular e vedere se riusciamo ad eseguire l’autenticazione. Andiamo nella cartella client-angular e lanciamo il solito comando ng serve, apriamo il browser all’indirizzo http://localhost:4200 e verifichiamo di essere reindirizzati alla pagina di autenticazione:

Purtroppo, nonostante la pagina di autenticazione sia raggiungibile, otteniamo un errore CORS, che sembrerebbe legittimo a causa della differenza di porta tra client (4200) e server (5000). Ma allora perché nell’articolo precedente lo stesso esempio funzionava senza problemi? Premesso che spesso e volentieri IdentityServer risponde con un errore CORS anche per errori diversi come, ad esempio un errore di accesso ai dati, in questo caso il problema segnalato è quello giusto.

Nell’articolo precedente, le origini dei Client registrati venivano automaticamente aggiunti alle origini autorizzate, quindi il localhost:4200 dovrebbe essere tra queste. Se però consultiamo la documentazione ufficiale, alla sezione CORS scopriamo un effetto collaterale del non aver usato Entity Framework:

“This default CORS implementation will be in use if you are using either the “in-memory” or EF-based client configuration that we provide. If you define your own IClientStore, then you will need to implement your own custom CORS policy service (see below)”.

Quindi non ci scoraggiamo e implementiamo il nostro CORS policy service:

public class RepositoryCorsPolicyService : ICorsPolicyService
{
    private readonly string[] _allowedOrigins;
 
    public RepositoryCorsPolicyService(IRepository repository)
    {
        _allowedOrigins = repository.All<Client>().SelectMany(x => x.AllowedCorsOrigins).ToArray();
    }
 
    public Task<bool> IsOriginAllowedAsync(string origin)
        => Task.FromResult(_allowedOrigins.Contains(origin));
}

Non ci resta che registrare la nostra implementazione:

public void ConfigureServices(IServiceCollection services)
{
    ...
    var builder = services.AddIdentityServer(options =>
    {
        ...
    })
 
    ...
    services.AddSingleton<ICorsPolicyService, RepositoryCorsPolicyService>();
}

Ricordatevi di aggiungere la vostra registrazione DOPO AddIdentityServer(), altrimenti sarà sovrascritta da quella standard che non interrogherà i vostri Client. Forziamo la rigenerazione dell’immagine Docker utilizzando il comando docker-compose build --no-cache e riproviamo con docker-compose up:

Questa volta tutto funzionerà correttamente, dandoci accesso all’applicazione subito dopo l’autenticazione. Se provate a lanciare anche i microservizi vedrete che risponderanno senza problemi. Finito quindi? Per completare il giro non ci resta che dockerizzare anche i due microservizi, rendendo configurabili i parametri di comunicazione con IdentityServer, dato che non siamo più in localhost, ma nella rete di default di Docker:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
        ...
    }).AddJwtBearer(o =>
    {
        o.Authority = Configuration.GetValue<string>("IDENTITY_AUTHORITY");
        o.Audience = Configuration.GetValue<string>("IDENTITY_AUDIENCE");
        o.RequireHttpsMetadata = Configuration.GetValue<bool>("IDENTITY_REQUIREHTTPSMETADATA");
    });
    ...
}

A questo punto, possiamo utilizzare lo stesso Dockerfile usato per IdentityServer (sono entrambe applicazioni ASP.NET Core) in cui modifichiamo solo il nome dell’assembly da lanciare su ENTRYPOINT:

...
ENTRYPOINT ["dotnet", "microservice1.dll"]
 
...
ENTRYPOINT ["dotnet", "microservice2.dll"]

Modifichiamo il file docker-compose.yml aggiungendo i due servizi:

version: '3.4'
services:
 microservice1:
   build: ./microservice1
   environment:
     - IDENTITY_AUTHORITY=http://identityserver
   ports:
     - "5002:80"
   depends_on:
     - identityserver
 
 microservice2:
   build: ./microservice2
   environment:
     - IDENTITY_AUTHORITY=http://identityserver
   ports:
     - "5003:80"
   depends_on:
     - identityserver
 
 identityserver:
   build: ./identityserver
   ...
 
 mongodb:
   image: mongo:latest
   ...

Notate che l’indirizzo dell’authority non è più http://localhost:5000 ma lo impostiamo su http://identityserver, utilizzando quindi il nome del servizio per risolvere l’ip del container di identityserver nella rete di default creata da Docker per noi. Lanciamo il comando docker-compose up, che impiegherà un po’ di tempo in più per creare le immagini dei microservizi, e testiamo le invocazioni:

Come potete notare, riceviamo un errore 401 perchè il nostro token non risulta valido a causa del valore di issuer. La documentazione ufficiale dello standard JWT ci dice:

“The JWT MUST contain an "iss" (issuer) claim that contains a unique identifier for the entity that issued the JWT. In the absence of an application profile specifying otherwise,compliant applications MUST compare issuer values using the Simple String Comparison method defined in Section 6.2.1 of RFC39862”

In localhost tutto funzionava perché IdentityServer genera per noi questo valore, come chiaramente espresso dalla documentazione ufficale:

“IssuerUri: Set the issuer name that will appear in the discovery document and the issued JWT tokens. It is recommended to not set this property, which infers the issuer name from the host name that is used by the clients.”

Quindi, alla generazione del token richiesta dal nostro cliente, viene utilizzato come issuer http://localhost:5000, mentre il nostro microservizio contatta IdentityServer per la validazione del token utilizzando l’authority http://identityserver che abbiamo configurato nello script docker-compose. In produzione non avremmo nessun problema, dato che questi coinciderebbero, ma in questo caso ibrido invece dobbiamo forzare la mano di IdentityServer, utilizzando un IssuerUri fissato:

var builder = services.AddIdentityServer(options =>
{
options.IssuerUri = Configuration.GetValue<string>("ISSUER_URI");
...
})
.AddMongoRepository(
Configuration.GetValue<string>("MONGO_CONNECTION"),
       Configuration.GetValue<string>("MONGO_DATABASE_NAME"))
.AddClients()
.AddIdentityApiResources()
.AddPersistedGrants();

Conclusioni

Con questa modifica tutto torna a funzionare correttamente! Potete verificare voi stessi scaricando ed eseguendo i sorgenti che trovate qui: https://github.com/apomic80/angular-microservices-identityserver.

Se volete approfondire Docker e Docker Compose, potete guardare le registrazioni delle dirette del nostro Antonio, che ogni venerdì tratta tematiche relative a DevOps in diretta sul suo canale Twitch: https://www.twitch.tv/turibbio.

Happy Coding!

Autore

Servizi

Evolvi la tua azienda