Autoryzacja i uwierzytelnienie w ASP.NET Core za pomocą IdentityServer 4 – Implementacja – część 2

Po przydługim wpisie z czystą teorią na temat IdentityServer 4, OAuth 2.0 i OpenID Connect w ASP.NET Core przyszedł czas na coś fajniejszego. Praktyczna implementacja IdentityServer 4. W trakcie tego posta stworzymy gotowe rozwiązanie oparte na powyższych technologiach plus kilku dodatkowych, które przedstawię poniżej. Oczywiście przykład oparty będzie na use case z poprzedniego posta.

Końcowym efektem będzie działająca aplikacja jak na filmiku poniżej:

Czego będziemy potrzebowali:

  1. IDE – Visual Studio, Code, co kto lubi,
  2. MS SQL – przykład jest na nim oparty, ale zawsze możesz użyć czegoś co lubisz
  3. .NET Core 3.1
  4. Biblioteki dla IdentityServer 4

Jak już wspomniałem w poprzednim wpisie, nowoczesne aplikacje do uwierzytelniania i autoryzacji użytkowników wykorzystują protokoły OAuth2.0 i OpenID Connect. Ręczna ich implementacja może zająć nam miesiące a i tak nie mamy pewności czy czegoś nie poknociliśmy. Z pomocą przychodzi nam ich gotowa implementacja za pomocą IdentityServer 4. Tak naprawdę jest on gotowcem, którego wystarczy zainstalować i możemy działać. Warto wspomnieć, że ma świetną dokumentację, którą znajdziecie tu IdentityServer 4  oraz dobrze przedstawione przykłady, które znajdują się tutaj: Samples.

Jeżeli przeczytałeś poprzedni post pewnie już wiesz, że IdentityServer 4 ma zaimplementowane kilka procesów dla autoryzacji użytkownika, w moim przykładzie wykorzystałem Implicity, który przedstawia się następująco:

Implicity GrantSzczegółowy opis poszczególnych kroków, które są wykonywane znajduje się we wstępnym poście.

Tworzymy strukturę projektu:

W przykładowym use case opisanym tu będziemy potrzebować w aplikacji trzech projektów. Pierwszy odpowiedzialny będzie za autoryzację i uwierzytelnienie użytkowników, zadaniem drugiego będzie udostępnienie zastrzeżonych zasobów i oczywiście projekt naszego klienta w naszym przypadku będzie to aplikacja www. Nie chcę tu wchodzić w szczegóły, ale jakiś opis jest potrzebny. Wszystkie trzy projekty oparte są o ASP.NET Core Web Application i .NET Core 3.1. W przypadku projektu:

  • dla uwierzytelnienia i autoryzacji jest to aplikacja MVC,
  • zastrzeżonym zasobem będzie API
  • klient oparty jest na templatce Angular 8  w Visual Studio.

Poniżej struktura solucji:

struktura projektu Implementacja IdentityServer 4 w ASP.NET Core

Autoryzacja i uwierzytelnienie:

Implementacje autoryzacji i uwierzytelnienia wykonałem w projekcie FR.IdentityServer. Za zadanie będzie miał właśnie uwierzytelnić i autoryzować użytkowników naszej aplikacji. W pierwszej kolejności potrzebujemy zainstalować trzy paczki nugetowe dla IdentityServer 4:

IdentityServer 4 Nuget

Pierwsza paczka IdentityServer4 zapewnia nam podstawowe funkcjonalności które posiadają implementację OAuth2.0 i OpenID Connect. Ponieważ przykład oparty jest o ASP.NET Core potrzebujemy jeszcze dwie dodatkowe biblioteki IdentityServer4.AspNetIdentity oraz IdentityServer4.EntityFramework. Pierwsza z nich zapewnia nam bezproblemową integrację z naszą aplikacją, druga pomoże nam zachować nasze ustawienia i użytkowników zgodnie z flow w bazie danych.

IdentityServer 4 wykorzystuje kilka tabel infrastrukturalnych, w których przechowuje dane konfiguracyjne jak zdefiniowane zasoby, klientów oraz dane operacyjne jak tokeny, czy zgody. Kiedy Zintegrujesz bibliotekę z bazą danych powstanie kilkanaście tabel jak niżej:

IdentityServer 4 i EntityFramework struktura

Aby taką strukturę uzyskać musimy odpowiednio skonfigurować IdentityServer 4 w naszej klasie Starup.cs. W pierwszej kolejności musimy podłączyć nasz DbContext. W naszym przypadku będą to aż trzy konteksty. Pierwszy  ApplicationDbContext, który wykorzystuje ASP.NET Core Identity do przechowywania danych naszych użytkowników loginy, hasła itp. PersistedGrantDbContext i ConfigurationDbContext, które dostarcza nam IdentityServer 4.

Krok 1: Dodanie ApplicationDbContext i podłączenie do bazy FreelancerIdentity:

services.AddDbContext<ApplicationDbContext>(o =>
            {
                o.UseSqlServer(Configuration.GetConnectionString("FreelancerIdentity"));
            });

            services.AddIdentity<ApplicationUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

Krok 2: Musimy wskazać gdzie będą przechowywane ustawienia IdentityServer 4 i zasoby użytkowników:

 var builder = services.AddIdentityServer()
                .AddConfigurationStore(options =>
                {
                    options.ConfigureDbContext = o =>
                    {
                        o.UseSqlServer(Configuration.GetConnectionString("FreelancerIdentity"), so =>
                        {
                            so.MigrationsAssembly(typeof(Startup).Assembly.GetName().Name);
                        });

                    };
                })
                .AddOperationalStore(options =>
                {
                    options.ConfigureDbContext = o =>
                    {
                        o.UseSqlServer(Configuration.GetConnectionString("FreelancerIdentity"), so =>
                            {
                                so.MigrationsAssembly(typeof(Startup).Assembly.GetName().Name);
                            });

                    };

                    // this enables automatic token cleanup. this is optional.
                    options.EnableTokenCleanup = true;
                })

W ostatniej linijce na screenie powyżej dodajemy naszego użytkownika, w tym przypadku jest to custowmowy użytkownik dziedziczący po IdentityUser z AspNetCore.Identity.

public class ApplicationUser : IdentityUser
    {
        public string Name { get; set; }
        public string CompanyName { get; set; }
        public string PictureUrl { get; set; }
    }

Do tej pory wszystkie dane przechowywane są w bazie danych. Istnieje jeszcze możliwość przechowywania ich w pamięci. Aby to zrobić należy do konfiguracji z Kroku 2 dodać poniższe trzy linijki. Pierwsza dodaje zasoby do jakich będziemy mieli dostęp, druga chronione przez nas API, trzecia konfiguracje aplikacji klienckich, które będą się łączyć do naszego serwera autoryzacyjnego:

                .AddInMemoryIdentityResources(IdentityServerConfiguration.GetIdentityResources())
                .AddInMemoryApiResources(IdentityServerConfiguration.GetApis())
                .AddInMemoryClients(IdentityServerConfiguration.GetClients())
                .AddAspNetIdentity<ApplicationUser>();

Krok 3: Konfigurujemy nasze zasoby:

Potrzebujemy do tego trzy rzeczy, o których już wspomniałem powyżej.

1. Dozwolone zakresy zgodnie z OpenID Connect. Mi potrzebne będa trzy: OpenId, Email i Profile.

public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Email(),
                new IdentityResources.Profile(),
            };
        }

2. Nasze zasoby. Czyli API, które chronimy.

public static IEnumerable<ApiResource> GetApis()
        {
            return new List<ApiResource>
            {
                new ApiResource("FinanceManagerAPI", "Finance Manager service")
            };
        }

3. Aplikacje kliencką, z której będziemy wykonywać requesty.

 public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client()
                {
                    ClientId = "spa",
                    ClientName = "freelancer web app",
                    AllowedGrantTypes = GrantTypes.Implicit,
                    AllowAccessTokensViaBrowser = true,
                    RedirectUris = {"http://localhost:4200/auth-callback"},
                    RequireConsent = false,
                    PostLogoutRedirectUris = {"http://localhost:4200/"},
                    AllowedCorsOrigins = {"http://localhost:4200"},
                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "FinanceManagerAPI"
                    }
                }
            };
        }

Tutaj wypada więcej powiedzieć. ClientId to identyfikator aplikacji z której będziemy się dobijać. AllowedGrantTypes określa proces dla autoryzacji użytkownika, ja wybieram Implicity. AllowAccessTokensViaBrowser pozwalamy na przekazywanie tokenów. RedirectUris określa gdzie wrócimy, gdy użytkownik zostanie już uwierzytelniony. RequireConsent mówi, czy wyświetlamy widok ze zgodami na dostęp do zakresów patrz pkt 1. PostLogoutRedirectUris określa gdzie trafimy jak się wylogujemy. AllowedCorsOrigins podajemy url z którego będziemy wykonywać requesty. AllowedScopes dozwolone dla aplikacji zakresy.

Krok 4: Zapisujemy nasze dane do bazy:

Jak już mamy wszystko skonfigurowane. Kto gdzie i jak będzie miał dostęp. Pozostaje nam tylko utrwalić to w bazie danych. Do tego celu możemy wykorzystać customowego DatabaseInitializer-a. Mój wygląda jak niżej:

 public class DatabaseInitializer
    {
        public static void Init(IServiceProvider provider, bool useInMemoryStores)
        {
            if (!useInMemoryStores)
            {
                provider.GetRequiredService<ApplicationDbContext>().Database.Migrate();
                provider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();
                provider.GetRequiredService<ConfigurationDbContext>().Database.Migrate();
            }
            InitializeIdentityServer(provider);

            var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
            var erloon = userManager.FindByNameAsync("erloon").Result;
            if (erloon == null)
            {
                erloon = new ApplicationUser
                {
                    Email = "erloon@wp.pl",
                    UserName = "erloon",
                    CompanyName = "sparkdata.pl"
                };
                var result = userManager.CreateAsync(erloon, "$AspNetIdentity10$").Result;
                if (!result.Succeeded)
                {
                    throw new Exception(result.Errors.First().Description);
                }

                erloon = userManager.FindByNameAsync("erloon").Result;

                result = userManager.AddClaimsAsync(erloon, new Claim[]{
                    new Claim(JwtClaimTypes.Name, "Maciej Kryca"),
                    new Claim(JwtClaimTypes.GivenName, "Maciej"),
                    new Claim(JwtClaimTypes.FamilyName, "Kryca"),
                    new Claim(JwtClaimTypes.Email, "erloon@wp.pl"),
                    new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
                    new Claim(JwtClaimTypes.WebSite, "https://sparkdata.pl"),
                    new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'localhost 10', 'postal_code': 11146, 'country': 'poland' }", 
                        IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json)
                }).Result;

                if (!result.Succeeded)
                {
                    throw new Exception(result.Errors.First().Description);
                }
                Console.WriteLine("erloon created");
            }
            else
            {
                Console.WriteLine("erloon already exists");
            }
        }

        private static void InitializeIdentityServer(IServiceProvider provider)
        {
            var context = provider.GetRequiredService<ConfigurationDbContext>();
            if (!context.Clients.Any())
            {
                foreach (var client in IdentityServerConfiguration.GetClients())
                {
                    context.Clients.Add(client.ToEntity());
                }
                context.SaveChanges();
            }

            if (!context.IdentityResources.Any())
            {
                foreach (var resource in IdentityServerConfiguration.GetIdentityResources())
                {
                    context.IdentityResources.Add(resource.ToEntity());
                }
                context.SaveChanges();
            }

            if (!context.ApiResources.Any())
            {
                foreach (var resource in IdentityServerConfiguration.GetApis())
                {
                    context.ApiResources.Add(resource.ToEntity());
                }
                context.SaveChanges();
            }
        }
    }

W tym momencie nastał czas na najtrudniejsze. Migracje 😉  Dla ułatwienia przygotowałem kilka gotowych komend.

# Migration instructions for IdentityServer project

* change `UseInMemoryStores` to `false` in **appsettings.json**

### Create migrations
* Add-Migration Initial -Context ConfigurationDbContext -OutputDir "Infrastructure/Database/Migrations/IdentityServer/Configuration"
* Add-Migration Initial -Context PersistedGrantDbContext -OutputDir "Infrastructure/Database/Migrations/IdentityServer/Persisted"
* Add-Migration Initial -Context ApplicationDbContext -OutputDir "Infrastructure/Database/Migrations/Application"

### Update database
* Update-Database -Context PersistedGrantDbContext
* Update-Database -Context ConfigurationDbContext
* Update-Database -Context ApplicationDbContext

Krok 5: Tworzymy endpointy do logowania i rejestracji:

Biblioteka IdentityServer 4 jest tak fajna, że dostarcza nam już gotowe przykłądy, które możemy wykorzystać w naszej aplikacji. Ja skorzystałem i lekko przerobiłem na własne potrzeby gotowe metody do logowania i rejestracji użytkowników. Warto tutaj dobrze wyjaśnić. Aplikacja kliencka w naszym wypadku angular tylko przekierowuje na serwer autoryzacyjny gdzie użytkownik będzie się logował. Dla uproszczenia wykorzystałem strony w ASP.NET MVC.

Logowanie:

Wykorzystuje tutaj gotowy serwis dostarczony przez ASP.NET Identity UserMenager.  Kolejna warta uwagi rzecz to tworzenie Claims-ów użytkownika, które dodajemy do kontekstu. Poźniej w aplikacji klienckiej będziemy mogli z nich skorzystać.

            if (ModelState.IsValid)
            {
                var user = await _userManager.FindByNameAsync(model.Username);
                if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
                {
                    await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.Name));

                    AuthenticationProperties props = null;
                    if (AccountOptions.AllowRememberLogin && model.RememberLogin)
                    {
                        props = new AuthenticationProperties
                        {
                            IsPersistent = true,
                            ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
                        };
                    };

                    await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("userName", user.UserName));
                    await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("name", user.Name));
                    await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("email", user.Email));
                    await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("role", Roles.Consumer));
                    await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("companyName", user.CompanyName));
                  

                    await HttpContext.SignInAsync(user.Id, user.UserName, props);

                    if (context != null)
                    {
                        if (await _clientStore.IsPkceClientAsync(context.ClientId))
                        {
                            return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                        }

                        return Redirect(model.ReturnUrl);
                    }

                    if (Url.IsLocalUrl(model.ReturnUrl))
                    {
                        return Redirect(model.ReturnUrl);
                    }
                    else if (string.IsNullOrEmpty(model.ReturnUrl))
                    {
                        return Redirect("~/");
                    }
                    else
                    {
                        throw new Exception("invalid return URL");
                    }
                }

                await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
                ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
            }

Rejestracja:

public async Task<IActionResult> Register([FromBody]RegisterRequestViewModel model)
        {

            if (!ModelState.IsValid)
            {
                var errorsList =
                    ModelState.Keys.SelectMany(key => ModelState[key].Errors.Select(error => new IdentityError()
                    {
                        Code = "Validation",
                        Description = error.ErrorMessage
                    }));

                return BadRequest(errorsList);
            }

            var user = new ApplicationUser { UserName = model.Email, Name = model.Name, CompanyName = model.CompanyName, Email = model.Email, Id = Guid.NewGuid().ToString() };

            var result = await _userManager.CreateAsync(user, model.Password);

            if (!result.Succeeded) return BadRequest(result.Errors);

            await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("userName", user.UserName));
            await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("name", user.Name));
            await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("email", user.Email));
            await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("role", Roles.Consumer));
            await _userManager.AddClaimAsync(user, new System.Security.Claims.Claim("companyName", user.CompanyName));

            return Ok(new RegisterResponseViewModel(user));
        }

W tym momencie to już wszystko. Mamy działający serwer autoryzacyjny oparty na IdentityServer 4. W kolejnej części dodam przykładowe API z chronionymi zasobami i aplikacje kliencką opartą o Angulara.

No comment yet, add your voice below!


Add a Comment

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *