Um exemplo de STS, usando IdentityServer e Identity, persistindo com RavenDB

Em um post anterior, indiquei que um servidor de identidades seria uma bela alternativa para um “primeiro microsserviço”.

Neste post, compartilho uma implementação básica, usando IdentityServer4 e Identity, usando RavenDB como mecanismo de persistência. O código-fonte está disponível no meu github.

Pela amplitude do tema, não me aprofundo em nenhuma das etapas. Mas, acho que compartilho um bom guia de como fazer as coisas acontecerem. Em casos de dúvida, compartilhe sua questão nos comentários (lembre-se, o código está no github).

Começando pela aplicação

Como de costume, resolvi separar a implementação do microsserviço de STS em uma “aplicação” e o servidor Web (que funciona como uma espécie de adapter para essa aplicação).

Quanto mais utilizo essa abordagem, mais sou simpático a ela. Pela estrutura da aplicação fica claro quais são as operações que resolvi suportar em meu STS.

Persistindo informações do IdentityServer4 no RavenDB

IdentityServer4 possui integração oficial com Entity Framework (o que torna o trabalho de persistência em SQL Server bem fácil). Entretanto, não me sinto confortável em usar um Banco de Dados relacional para essa finalidade. Tanto mais convicto em fico quando inspeciono a implementação do suporte ao EntityFramework e vejo a quantidade de tabelas que eram criadas, mappers, modelos de persistência, …

No mesmo repositório, você encontra minha proposta de persistência para RavenDB.

Como você pode ver, não é tanto código assim.

Para mostrar como a implementação é trivial, compartilho a persistência de aplicações clientes.

public interface IRavenDBClientStore : IClientStore
{
    Task StoreAsync(Client client);
    Task HasStoredClients();
}
internal class ClientStore : IRavenDBClientStore
{
    private readonly IAsyncDocumentSession _session;

    public ClientStore(IAsyncDocumentSession session)
    {
        _session = session;
    }

    public async Task FindClientByIdAsync(string clientId) =>
        (await _session.LoadAsync(RavenClient.DocId(clientId)));

    public async Task StoreAsync(Client client)
    {
        await _session.StoreAsync(client, RavenClient.DocId(client.ClientId));
        await _session.SaveChangesAsync();
    }

    public Task HasStoredClients()
    {
        return _session.Query()
            .Customize(o => o.WaitForNonStaleResults())
            .AnyAsync();
    }

    internal class RavenClient : Client
    {
        public string Id => DocId(ClientId);

        public static string DocId(string clientId)
        {
            return $"client/{clientId}";
        }
    }
}

Simplesmente adoro essa abordagem “sem configuração” do Raven. Os próprios objetos de modelo do IdentityServer4 são facilmente persistidos.

Persistindo informações do AspNetCore Identity no RavenDB

Na minha solução, utilizo a integração entre IdentityServer4 e AspNetCore Identity para salvar informações dos usuários. Novamente, não queria utilizar SQL server para salvar informações (já disse que odeio Migrations?). Por causa disso, implementei persistência do Identity também no RavenDB.

Nada demais, novamente.

Já detalhei o processo de escrita desse componente em um post anterior (se está interessado, recomendo a leitura).

Usando Docker

Como disse em um post anterior, gosto muito da ideia de usar Docker quando estou desenvolvendo microsserviços. Aqui, a composição que usei nesse exemplo.

version: '3'

services:
  playground:
    image: playground
    build:
      context: .
      dockerfile: Playground/Dockerfile
    depends_on:
      - identity.web

  identity.web:
    image: identity.web
    build:
      context: .
      dockerfile: Identity.Web/Dockerfile
    depends_on:
      - identities.data
    
  identities.data:
    image: ravendb/ravendb

Playground é o nome do serviço que escrevi em um post anterior (onde demonstro como lidar com instabilidade de conexão), e que resolvi reutilizar aqui, adicionando segurança. Vejam que estou usando RavenDB também em um container.

Aqui, o arquivo em que defino parâmetros (composição para docker).

version: '3'

services:
  playground:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://0.0.0.0:80
      - AuthenticationAuthority=http://10.0.75.1:5000
    ports:
      - "5001:80"

  identity.web:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://0.0.0.0:80
      - PlaygroundApi=http://localhost:5001
      - RavenDbUrl=http://identities.data:8080
      - RavenDbDatabase=Identities
    ports:
      - "5000:80"

  identities.data:
    ports:
      - "8080:8080"

Um detalhe muito importante é a utilização do endereço 10.0.75.1. Trata-se do endereço fornecido pelo docker para que um serviço consiga interagir com endereços da máquina host.

Antes de rodar a aplicação, é importante abrir o manager do RavenDB (localhost:8080), configurar e criar o banco de dados “Identities” que estou usando em tempo de execução.

Configurando o STS

No IdentityServer, é importante fazer as devidas configurações para indicar quais vão ser as aplicações clientes e, principalmente, que recursos essas aplicações poderão acessar (essa informação é persista no RavenDB).

Abaixo, a configuração que criei para esse exemplo:

public static IEnumerable GetApis() => new[]
{
    new ApiResource("playground", "PlaygroundService")
};

public static IEnumerable GetResources() => new IdentityResource[]
{
    new IdentityResources.OpenId(),
    new IdentityResources.Profile()
};

public static IEnumerable GetClients(
    IDictionary<string, string> clientsUrl
) => new[]
{
    new Client
    {
        ClientId = "playgroundswaggerui",
        ClientName = "Playground Swagger UI",
        AllowedGrantTypes = GrantTypes.Implicit,
        AllowAccessTokensViaBrowser = true,

        RedirectUris = { $"{clientsUrl["PlaygroundApi"]}/swagger/o2c.html" },
        PostLogoutRedirectUris = { $"{clientsUrl["PlaygroundApi"]}/swagger/" },

        AllowedScopes =
        {
            "playground"
        },
        RequireConsent = false
    } 
};

Explicar detalhes desse código está além dos meus objetivos nesse post. Entretanto, é fácil reconhecer que estamos criando configuração para protejer uma API e estamos nos preparando para suportar uma aplicação cliente (no caso, o swagger da api que desenvolvemos no exemplo do post anteerior).

Abaixo, a inicialização da aplicação de identidade.

using System;
using BuildingBlocks.AspnetCoreIdentity.RavenDB;
using Identity.Application.Setup;
using IdentityServer4.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Identity.Application
{
    public static class Startup
    {
        public static IServiceCollection AddIdentityApplication(
            this IServiceCollection services, IConfiguration configuration
        )
        {
            services.AddDocumentStore(configuration,
                new CollectionMapping(typeof(IdentityResource), "IdentityResources")
                );

            services.AddRavenDBIdentity(
                DocumentStoreHolder.Store,
                DocumentStoreHolder.DatabaseName
            );

            services.Configure(options =>
            {
                // Password settings
                options.Password.RequireDigit = false;
                options.Password.RequiredLength = 6;
                options.Password.RequireNonAlphanumeric = false;
                options.Password.RequireUppercase = false;
                options.Password.RequireLowercase = false;
                options.Password.RequiredUniqueChars = 1;
            });

            services.ConfigureApplicationCookie(options =>
            {
                // Cookie settings
                options.Cookie.HttpOnly = true;
                options.Cookie.Expiration = TimeSpan.FromDays(150);
                options.LoginPath = "/Accounts/Login";
                options.LogoutPath = "/Accounts/Logout";
                options.AccessDeniedPath = "/Accounts/AccessDenied";
                options.SlidingExpiration = true;
            });

            services
                .AddIdentityServer()
                .AddDeveloperSigningCredential()
                .AddRavenDBConfigurationStore(
                    DocumentStoreHolder.Store,
                    DocumentStoreHolder.DatabaseName,
                    async (clientStore, resourceStore) => await BaseConfig.LoadSeed(
                        clientStore, resourceStore, configuration
                        )
                )
                .AddRavenDBOperationalStore(
                    DocumentStoreHolder.Store,
                    DocumentStoreHolder.DatabaseName
                )
                .AddAspNetIdentity()
                .AddProfileService();

            return services;
        }

        public static IApplicationBuilder UseIdentityApplication(
            this IApplicationBuilder app
        )
        {
            app.UseRavenDBIdentity();
            app.UseAuthentication();
            app.UseIdentityServer();
            return app;
        }
    }
}

Configurando a API a ser protegida

A configuração da API que iremos proteger é simples.

using System.IdentityModel.Tokens.Jwt;
using BuildingBlocks.Core;
using BuildingBlocks.Mvc;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;

// ReSharper disable once CheckNamespace
namespace Microsoft.Extensions.DependencyInjection
{
    public static class SetupIdentity
    {
        public static IServiceCollection AddCustomIdentity(
            this IServiceCollection services, 
            IApiInfo apiInfo
            )
        {
            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;

            }).AddJwtBearer(options =>
            {
                options.Authority = apiInfo.AuthenticationAuthority;
                options.RequireHttpsMetadata = false;
                options.Audience = apiInfo.JwtBearerAudience;
            });

            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services.AddScoped<IUser, HttpContextUser>();

            return services;
        }
    }
}

Basicamente, configuro qual será a origem do token que será utilizado na autenticação.

Configurando o Swagger

Para o cliente Swagger funcionar adequadamente, precisamos informar detalhes do servidor de identidades.

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

using Swashbuckle.AspNetCore.Examples;
using Swashbuckle.AspNetCore.Swagger;

using BuildingBlocks.Core;

namespace BuildingBlocks.Swagger
{
    public static class Startup
    {
        public static IServiceCollection AddCustomSwagger(
            this IServiceCollection services,
            IApiInfo apiInfo

        ) => services
            .AddSwaggerGen(options =>
            {
                options.DescribeAllEnumsAsStrings();

                options.SwaggerDoc(apiInfo.Version, new Info
                {
                    Title = apiInfo.Title,
                    Version = apiInfo.Version,
                    Description = apiInfo.Version
                });

                if (apiInfo.AuthenticationAuthority != null)
                {
                    options.AddSecurityDefinition("oauth2", new OAuth2Scheme
                    {
                        Type = "oauth2",
                        Flow = "implicit",
                        AuthorizationUrl = $"{apiInfo.AuthenticationAuthority}/connect/authorize",
                        TokenUrl = $"{apiInfo.AuthenticationAuthority}/connect/token",
                        Scopes = apiInfo.Scopes
                    });
                }
                
                options.OperationFilter(apiInfo);
                options.OperationFilter();
                options.OperationFilter();
            });

        public static IApplicationBuilder UseCustomSwagger(
            this IApplicationBuilder app,
            IApiInfo apiInfo
        ) => app
            .UseSwagger()
            .UseSwaggerUI(c =>
            {
                c.SwaggerEndpoint($"/swagger/{apiInfo.Version}/swagger.json", $"{apiInfo.Title} {apiInfo.Version}");
                if (apiInfo.AuthenticationAuthority != null)
                {
                    c.ConfigureOAuth2(
                        apiInfo.SwaggerAuthInfo.ClientId,
                        apiInfo.SwaggerAuthInfo.Secret,
                        apiInfo.SwaggerAuthInfo.Realm,
                        $"{apiInfo.Title} - ${apiInfo.Version} - Swagger UI"
                    );
                }
            });
    }
}

Resultado

Ao executar a aplicação acessando o serviço que está protegido, tenho acesso, na interface do próprio swagger, ao mecanismo que irá me encaminhar para o servidor de autenticação.

Concluindo

No código de hoje, passamos por AspNetCore Identity, IdentityServer4, RavenDB e Docker.

Um bocado de tecnologias que você deveria conhecer (acho que já conhece).  Como resultado, temos um exemplo consistente de como construir um microsserviço de STS.

Caso tenha dúvidas ou sugestões, compartilhe nos comentários.

Crédito da capa para unsplash-logoHans-Peter Gauster

3 Comentários
  1. […] resolvi implementar a persistência do IdentityServer4 para RavenDB. Por isso, resolvi dar uma olhada na implementação oficial para EntityFramework. E encontrei […]

  2. Wallison Santos

    Olá Elemar! Tudo bem? O repositório ta dando 404. Alguma chance da gente poder ver esse código novamente? Grato desde já.

  3. Nick Carter

    Seu link do github.com não está funcionando. Você pode por favor postar um link github válido?

    Your github.com link is not working. Can you please post a valid github link.

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *