.NET

15 dez, 2020

ASP.NET Core 3.1 — IdentityServer4 — Clients (Parte 7)

Publicidade
Image for post

Na Parte 6, eu mostrei o básico de como configurar a aplicação web com Identity e deixá-la preparada para suportar o Bootstrap e o JQuery. Este foi o primeiro passo para configurar a tela de login para autenticar um usuário.

O objetivo principal deste post é prepararmos o IdentityServer4 para que ele possa redirecionar uma aplicação cliente para sua tela de login e após o usuário se logar, que o IdentityServer4 redirecione o usuário para o lugar de onde ele veio, considerando também o logout.

Na Parte 3 eu falei sobre Clients para API, agora falaremos de um Client para uma aplicação MVC (Model View Controller) que veremos no próximo post, mas antes de começarmos chegou o momento de falarmos sobre os grant types, pois lá na API usamos a opção client_credentials, agora como nossa aplicação cliente não é uma API, vamos ver outras opções.

Grant Types

As especificações do OpenID Connect (oidc) e OAuth 2.0 define os grant types também podendo ser chamado de flow ou protocol flows. Os grant types indicam como um Client pode interagir com um determinado token, Vamos ver alguns deles que vamos utilizar.

  • client credentials: é o meio mais simples de comunicação Machine to Machine Communication onde o token é obrigatório mas as informações de um determinado usuário não estão presentes no token.
  • hybrid: através do response, é retornado o code id_token conforme configuração, além disso temos também as claims que podem ser customizadas, nelas principalmente estão as informações do usuário que se logou na aplicação com seu usuário e senha.
  • hybrid and client credentials: é a junção das características dos grant types anteriores.

Para o Client que será configurado para a aplicação MVC utilizaremos o grant type hybrid mais o client credentials, pois desta forma nossa aplicação MVC poderá acessar a API de pagamento com o mesmo token do usuário autenticado.

Client

Quando eu falei em criar uma aplicação MVC, para que ela tenha permissão de acessar o IdentityServer4 é necessário que um Client seja criado, então criei um Client com o nome de mvc-client que está no conteúdo do arquivo IdentityServerSeed.cs.

using IdentityProvider.Data;
using IdentityProvider.Models;
using IdentityServer4;
using IdentityServer4.EntityFramework.DbContexts;
using IdentityServer4.EntityFramework.Mappers;
using IdentityServer4.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;

namespace IdentityProvider.Seeds
{
    public static class IdentityServerSeed
    {
        public static void RunMigration(this IApplicationBuilder app)
        {
            using var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope();

            serviceScope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();
            serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>().Database.Migrate();
            serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
        }

        public static void SeeClient(this IApplicationBuilder app)
        {
            using var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope();

            var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();

            #region Payment Client
            if (!context.Clients.Any(_ => _.ClientId == "payment-client"))
            {
                var client = new Client
                {
                    ClientId = "payment-client",
                    ClientName = "Client Credentials for Payment API",
                    AllowedGrantTypes = GrantTypes.ClientCredentials,

                    ClientSecrets =
                    {
                        new Secret("payment-secret".Sha256())
                    },

                    AllowedScopes =
                    {
                        "payment"
                    },

                    AllowAccessTokensViaBrowser = true,
                    AllowOfflineAccess = true,
                    AlwaysSendClientClaims = true,
                    AlwaysIncludeUserClaimsInIdToken = true
                };

                context.Clients.Add(client.ToEntity());
                context.SaveChanges();
            }
            #endregion

            #region MVC Client
            if (!context.Clients.Any(_ => _.ClientId == "mvc-client"))
            {
                var client = new Client
                {
                    ClientId = "mvc-client",
                    ClientSecrets = { new Secret("mvc-client-secret".Sha256()) },

                    AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
                    RequireConsent = false,
                    RequirePkce = false,

                    // where to redirect to after login
                    RedirectUris = { "http://localhost:5004/signin-oidc" },
                    
                    // where to redirect to after logout
                    PostLogoutRedirectUris = { "http://localhost:5004/signout-callback-oidc" },

                    AllowedScopes = new List<string>
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "payment"
                    },

                    AllowOfflineAccess = true
                };

                context.Clients.Add(client.ToEntity());
                context.SaveChanges();
            }
            #endregion
        }

        public static void SeedResources(this IApplicationBuilder app)
        {
            using var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope();

            var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();

            if (!context.IdentityResources.Any())
            {
                var resources = new List<IdentityResource>
                {
                    new IdentityResources.OpenId(),
                    new IdentityResources.Profile(),
                    new IdentityResources.Email()
                };

                foreach (var resource in resources)
                {
                    context.IdentityResources.Add(resource.ToEntity());
                }

                context.SaveChanges();
            }
        }

        public static void SeedApiResource(this IApplicationBuilder app)
        {
            using var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope();

            var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();

            if (!context.ApiResources.Any())
            {
                var apiResource = new ApiResource("payment", "Payment API");
                context.ApiResources.Add(apiResource.ToEntity());
                context.SaveChanges();
            }
        }

        public static void SeedUser(this IApplicationBuilder app)
        {
            using var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope();

            var context = serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            if (!context.Users.Any(_ => _.Email == "admin@admin.com"))
            {
                var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();

                var user = new ApplicationUser { UserName = "admin@admin.com", Email = "admin@admin.com", EmailConfirmed = true };
                var result = userManager.CreateAsync(user, "Abc@12345").Result;

                if (result.Succeeded)
                {
                    var roleManager = serviceScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
                    _ = roleManager.CreateAsync(new IdentityRole { Name = "Master" }).Result;

                    user = userManager.FindByNameAsync("admin@admin.com").Result;

                    userManager.AddToRoleAsync(user, "Master");
                }
            }
        }
    }
}

Vamos alguns detalhes sobre esse Client:

  • RedirectUris: Após efetuar o login, é para esta URL que o usuário será redirecionado, ou seja, a URL da aplicação MVC.
  • PostLogoutRedirectUris: Quando o usuário clicar em Sair, é para esta URL que ele será redirecionado.
  • AllowedScopes: Aqui estão os escopos permitidos, um deles é o OpenID Connect ( IdentityServerConstants.StandardScopes.OpenId), caso contrário não seria permitido que um Client híbrido fosse autorizado a se conectar no IdentityServer4. E o “payment” que é o escopo da API, isso vai permitir que a nossa aplicação MVC consiga acessar a API de pagamento, vamos falar deste ponto específico nos próximos posts.
  • Método SeedResources: Tive que dizer para o IdentityServer4 que ele precisa aceitar os recursos de escopo, novamente aqui tivemos que cadastrar o OpenID Connect.
  • Método SeedUser: Aqui eu cadastrei via código mesmo um usuário para o Identity, é com ele que vamos nos logar. Foi feito desta forma apenas para facilitar, poderia ter cadastrado pela interface, mas é interessante para que você saiba como é possível fazer caso precise personalizar.

Statup.cs

Fiz 2 alterações aqui.

using IdentityProvider.Data;
using IdentityProvider.Models;
using IdentityProvider.Seeds;
using IdentityServer4.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;

namespace IdentityProvider
{
    public class Startup
    {
        private readonly IConfiguration _configuration;
        private readonly IWebHostEnvironment _webHostEnvironment;
        public Startup(IConfiguration configuration, IWebHostEnvironment webHostEnvironment)
        {
            _configuration = configuration;
            _webHostEnvironment = webHostEnvironment;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();

            var identityServer = services.AddIdentityServer(options =>
            {
                options.Events.RaiseErrorEvents = true;
                options.Events.RaiseInformationEvents = true;
                options.Events.RaiseFailureEvents = true;
                options.Events.RaiseSuccessEvents = true;
            });

            #region Thumbprint
            if (_webHostEnvironment.IsDevelopment())
                identityServer.AddDeveloperSigningCredential();
            else
                identityServer.AddSigningCredential("4DFF9B8EBB5314B9A62EFA72DA8B4D7658231C05", StoreLocation.CurrentUser, NameType.Thumbprint);
            #endregion

            #region Identity configuration
            services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(_configuration.GetConnectionString("DefaultConnection")));

            services.AddIdentity<ApplicationUser, IdentityRole>(config =>
            {
                config.SignIn.RequireConfirmedEmail = true;
            })
            .AddRoles<IdentityRole>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddDefaultUI()
            .AddDefaultTokenProviders();

            // esta configuração é para o token gerado e enviado para o usuário para recuperar a senha e para confirmar o e-mail do seu cadastro
            // o token é enviado por e-mail para os dois casos
            services.Configure<DataProtectionTokenProviderOptions>(o => o.TokenLifespan = TimeSpan.FromHours(1));
            #endregion

            #region IdentityServer configuration
            var connectionString = _configuration.GetConnectionString("DefaultConnection");
            var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

            identityServer.AddConfigurationStore(options =>
            {
                options.ConfigureDbContext = builder => builder.UseSqlServer(connectionString,
                    sqlServerOptionsAction: sqlOptions =>
                    {
                        sqlOptions.MigrationsAssembly(migrationsAssembly);
                        sqlOptions.EnableRetryOnFailure(maxRetryCount: 15, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null);
                    });
            })
            .AddOperationalStore(options =>
            {
                options.ConfigureDbContext = builder => builder.UseSqlServer(connectionString,
                    sqlServerOptionsAction: sqlOptions =>
                    {
                        sqlOptions.MigrationsAssembly(migrationsAssembly);
                        sqlOptions.EnableRetryOnFailure(maxRetryCount: 15, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null);
                    });
            })
            .AddAspNetIdentity<ApplicationUser>();

            services.AddAuthentication();
            #endregion
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.RunMigration();

            app.SeeClient();
            app.SeedResources();
            app.SeedApiResource();

            app.SeedUser();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");

                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
                app.UseHttpsRedirection();
            }

            app.UseStaticFiles();

            app.UseRouting();
            //app.UseAuthentication(); // UseAuthentication not needed -- UseIdentityServer add this
            app.UseIdentityServer();
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapDefaultControllerRoute();
                endpoints.MapRazorPages();
            });
        }
    }
}

Chamada dos métodos app.SeedResources(); e app.SeedUser();.

Flow

Mais acima falei do flow, então o fluxo de funcionamento — sequenciamento — funciona da seguinte forma:

  1. Usuário clica no botão de login;
  2. Usuário é redirecionado para a tela de login do IdentityServer4 (repare na mudança da URL);
  3. Usuário informa seu usuário (e-mail) e senha;
  4. Em caso de sucesso no login, usuário é redirecionado para a aplicação MVC (repare na mudança da URL);

Para algo mais próximo possível disso que falamos, vamos usar a página do Meetup como exemplo:

  • Acesse o site do Meetup;
  • Clique em login;
  • Use o botão do Facebook, neste momento uma tela vai ser aberta e vai redirecionar você para o Facebook, você vai usar o seu e-mail e senha da sua conta do Facebook e em caso de sucesso no login você vai retornar para o site do Meetup já autenticado.

O que fizemos com o IdentityServer4 foi transformá-lo no login do Facebook, o flow é exatamente o mesmo.

Login

Aqui eu tentei deixar a tela de login mais próxima possível do Facebook.
Execute o projeto e acesse a URL conforme a image a seguir

Image for post

Como o foco não está na interface, seria interessante você baixar o projeto para pegar todas as alterações, ou comparar o branch anterior com este novo que foi criado. Acesse o código fonte no GitHub.

Continua ASP.NET Core 3.1 — IdentityServer4 — MVC-Client (Parte 8).