Blazor Client Side Online Store: Teil 1 - Autorisierung oidc (oauth2) + Identity Server4


Hallo habr Also ja, in meinem letzten Artikel habe ich versucht, Todo List auf Blazor Wasm zu machen und war zufrieden. Jetzt habe ich mich entschlossen, etwas ernst zu nehmen, um es auszuprobieren. Ich werde eine einfache SPA-Benutzeroberfläche auf Blazor für einen einfachen fiktiven Online-Shop erstellen. So nah wie möglich an der Einsatzmöglichkeit. Zunächst werde ich die Autorisierung von Benutzern und deren Trennung nach Rollen aufzeichnen, sodass der Administrator und der normale Benutzer eine etwas andere Benutzeroberfläche sehen. Ich habe all dies auch in Docker-Bildern gesammelt und in die Docker-Registrierung hochgeladen. Für Details begrüßen Sie zur Katze.

Inhalt




Referenzen


Quellcode
Docker-Registrierungsabbilder

Starten Sie


Es ist erforderlich, dass Sie Docker bereits mit Docker Compose ( Tyk ) installiert und mit dem Internet verbunden haben, da Sie meine Bilder herunterladen müssen.

Installieren Sie .net core und führen Sie diese Befehle in Windows PowerShell aus, um die Zertifikate zu erstellen, die für das Funktionieren von microservices erforderlich sind.

dotnet --info dotnet dev-certs https --trust dotnet dev-certs https -ep $env:APPDATA\ASP.NET\Https\blazor-eshop-api.pfx -p 1234Qwert dotnet dev-certs https -ep $env:APPDATA\ASP.NET\Https\blazor-eshop-spa-angular.pfx -p 1234Qwert dotnet dev-certs https -ep $env:APPDATA\ASP.NET\Https\blazor-eshop-spa-blazor.pfx -p 1234Qwert dotnet dev-certs https -ep $env:APPDATA\ASP.NET\Https\blazor-eshop-sso.pfx -p 1234Qwert 


Um ein Projekt zu starten, müssen Sie die Datei docker-compose.yml herunterladen (oder ihren Inhalt in eine Datei mit demselben Namen kopieren) und den Befehl docker-compose up in dem Verzeichnis ausführen, in dem sich diese Datei befindet. Microservices sind Listening-Adressen
https: // localhost: 8000
https: // localhost: 8001
https: // localhost: 8002
und https: // localhost: 8003 .

Bibliothek zur Autorisierung eines WASM-Clients in einem Browser


Installieren Sie www.nuget.org/packages/Sotsera.Blazor.Oidc und genießen Sie das Leben

Konfigurieren Sie Identity Server4


Im Allgemeinen habe ich einen vorgefertigten Server genommen und dort die Einstellungen für meinen SPA-Client hinzugefügt. Das Einrichten von Identity Server4 selbst würde den Rahmen dieses Artikels sprengen, da es sich um Blazor handelt. Wenn Sie interessiert sind, können Sie in meinen Quellen suchen.

Fügen Sie unseren Kunden zur Liste der verfügbaren Kunden hinzu

  new Client { ClientId = "spaBlazorClient", ClientName = "SPA Blazor Client", RequireClientSecret = false, RequireConsent = false, RedirectUris = new List<string> { $"{clientsUrl["SpaBlazor"]}/oidc/callbacks/authentication-redirect", $"{clientsUrl["SpaBlazor"]}/_content/Sotsera.Blazor.Oidc/silent-renew.html", $"{clientsUrl["SpaBlazor"]}", }, PostLogoutRedirectUris = new List<string> { $"{clientsUrl["SpaBlazor"]}/oidc/callbacks/logout-redirect", $"{clientsUrl["SpaBlazor"]}", }, AllowedCorsOrigins = new List<string> { $"{clientsUrl["SpaBlazor"]}", }, AllowedGrantTypes = GrantTypes.Code, AllowedScopes = { "openid", "profile", "email", "api" }, AllowOfflineAccess = true, RefreshTokenUsage = TokenUsage.ReUse } 

Um mehr Rollen im JWT-Token zu erhalten, implementieren wir unseren IProfileService

 using IdentityModel; using IdentityServer4.Models; using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.eShopOnContainers.Services.Identity.API.Models; using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; namespace Microsoft.eShopOnContainers.Services.Identity.API.Services { public class ProfileService : IProfileService { private readonly UserManager<ApplicationUser> _userManager; public ProfileService(UserManager<ApplicationUser> userManager) { _userManager = userManager; } async public Task GetProfileDataAsync(ProfileDataRequestContext context) { var subject = context.Subject ?? throw new ArgumentNullException(nameof(context.Subject)); var subjectId = subject.Claims.Where(x => x.Type == "sub").FirstOrDefault().Value; var user = await _userManager.FindByIdAsync(subjectId); if (user == null) throw new ArgumentException("Invalid subject identifier"); var claims = GetClaimsFromUser(user); context.IssuedClaims = claims.ToList(); var roles = await _userManager.GetRolesAsync(user); foreach (var role in roles) { context.IssuedClaims.Add(new Claim(JwtClaimTypes.Role, role)); } } async public Task IsActiveAsync(IsActiveContext context) { var subject = context.Subject ?? throw new ArgumentNullException(nameof(context.Subject)); var subjectId = subject.Claims.Where(x => x.Type == "sub").FirstOrDefault().Value; var user = await _userManager.FindByIdAsync(subjectId); context.IsActive = false; if (user != null) { if (_userManager.SupportsUserSecurityStamp) { var security_stamp = subject.Claims.Where(c => c.Type == "security_stamp").Select(c => c.Value).SingleOrDefault(); if (security_stamp != null) { var db_security_stamp = await _userManager.GetSecurityStampAsync(user); if (db_security_stamp != security_stamp) return; } } context.IsActive = !user.LockoutEnabled || !user.LockoutEnd.HasValue || user.LockoutEnd <= DateTime.Now; } } private IEnumerable<Claim> GetClaimsFromUser(ApplicationUser user) { var claims = new List<Claim> { new Claim(JwtClaimTypes.Subject, user.Id), new Claim(JwtClaimTypes.PreferredUserName, user.UserName), new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName) }; if (!string.IsNullOrWhiteSpace(user.Name)) claims.Add(new Claim("name", user.Name)); if (!string.IsNullOrWhiteSpace(user.LastName)) claims.Add(new Claim("last_name", user.LastName)); if (!string.IsNullOrWhiteSpace(user.CardNumber)) claims.Add(new Claim("card_number", user.CardNumber)); if (!string.IsNullOrWhiteSpace(user.CardHolderName)) claims.Add(new Claim("card_holder", user.CardHolderName)); if (!string.IsNullOrWhiteSpace(user.SecurityNumber)) claims.Add(new Claim("card_security_number", user.SecurityNumber)); if (!string.IsNullOrWhiteSpace(user.Expiration)) claims.Add(new Claim("card_expiration", user.Expiration)); if (!string.IsNullOrWhiteSpace(user.City)) claims.Add(new Claim("address_city", user.City)); if (!string.IsNullOrWhiteSpace(user.Country)) claims.Add(new Claim("address_country", user.Country)); if (!string.IsNullOrWhiteSpace(user.State)) claims.Add(new Claim("address_state", user.State)); if (!string.IsNullOrWhiteSpace(user.Street)) claims.Add(new Claim("address_street", user.Street)); if (!string.IsNullOrWhiteSpace(user.ZipCode)) claims.Add(new Claim("address_zip_code", user.ZipCode)); if (_userManager.SupportsUserEmail) { claims.AddRange(new[] { new Claim(JwtClaimTypes.Email, user.Email), new Claim(JwtClaimTypes.EmailVerified, user.EmailConfirmed ? "true" : "false", ClaimValueTypes.Boolean) }); } if (_userManager.SupportsUserPhoneNumber && !string.IsNullOrWhiteSpace(user.PhoneNumber)) { claims.AddRange(new[] { new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber), new Claim(JwtClaimTypes.PhoneNumberVerified, user.PhoneNumberConfirmed ? "true" : "false", ClaimValueTypes.Boolean) }); } return claims; } } } 

Hier der springende Punkt in diesem Codeteil

 var roles = await _userManager.GetRolesAsync(user); foreach (var role in roles) { context.IssuedClaims.Add(new Claim(JwtClaimTypes.Role, role)); } 

und füge es zu asp.net hinzu

 services.AddIdentityServer().AddProfileService<ProfileService>() 

Projekterstellung


Hier habe ich mich für den gehosteten ASP.NET Core entschieden, da ich die Einstellungen einfacher übertragen konnte. Einfacher, ein Docker-Image zusammenzusetzen. Falls gewünscht, können Sie auch Nginx im Container hosten.





Übertragen von Einstellungen aus der Konfigurationsdatei und Umgebungsvariablen


Serverseite


Einstellungsmodell hinzufügen

 public class ConfigModel { public string SsoUri { get; set; } = string.Empty; public string ApiUri { get; set; } = string.Empty; } 

Registrieren Sie es in Startup.cs

 public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddResponseCompression(opts => { opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( new[] { "application/octet-stream" }); }); services.Configure<ConfigModel>(Configuration); } 

Weitergabe an den Kunden als json

 using BlazorEShop.Shared.Presentation; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace BlazorEShop.Spa.BlazorWasm.Server.Controllers { [Route("api/v1/config")] [ApiController] public class ConfigController : ControllerBase { private readonly IOptionsSnapshot<ConfigModel> _configuration; public ConfigController(IOptionsSnapshot<ConfigModel> configuration) { _configuration = configuration; } // GET: api/<controller> [HttpGet] public ConfigModel Get() { return _configuration.Value; } } } 

Client-Seite


Wir holen die Einstellungen vom Server und fügen sie unserem DI-Container hinzu

 using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using BlazorEShop.Shared.Presentation; using Microsoft.AspNetCore.Blazor.Hosting; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; namespace BlazorEShop.Spa.BlazorWasm.Client { public class Program { public static void Main(string[] args) { Task.Run(async () => { ConfigModel cfg = null; var host = BlazorWebAssemblyHost.CreateDefaultBuilder().Build(); using (var scope = host.Services.CreateScope()) { var nm = scope.ServiceProvider.GetRequiredService<NavigationManager>(); var uri = nm.BaseUri; Console.WriteLine($"BASE URI: {uri}"); cfg = await GetConfig($"{(uri.EndsWith('/') ? uri : uri + "/")}api/v1/config"); } await BlazorWebAssemblyHost .CreateDefaultBuilder() .ConfigureServices(x => x.AddScoped<ConfigModel>(y => cfg)) .UseBlazorStartup<Startup>() .Build() .StartAsync() .ContinueWith((a, b) => Console.WriteLine(a.Exception), null); }); Console.WriteLine("END MAIN"); } private static async Task<ConfigModel> GetConfig(string url) { using var client = new HttpClient(); var cfg = await client .GetJsonAsync<ConfigModel>(url); return cfg; } } } 

Da Blazor Wasm die asynchrone Leere Main nicht unterstützt und der Versuch, das Ergebnis von Task abzurufen, zu einem Deadlock führt, weil wir nur einen Thread haben, mussten wir alles in Task.Run (async () => {}) umbrechen.

Aktivieren der oidc (oauth2) -Bibliothek auf der Clientseite


Wir rufen services.AddOidc mit den Einstellungen auf, die wir vom Server in ConfigModel erhalten haben.

 using Microsoft.AspNetCore.Components.Builder; using Microsoft.Extensions.DependencyInjection; using Sotsera.Blazor.Oidc; using System; using System.Net.Http; using System.Threading.Tasks; using BlazorEShop.Shared.Presentation; using Microsoft.AspNetCore.Components; namespace BlazorEShop.Spa.BlazorWasm.Client { public class Startup { public async void ConfigureServices(IServiceCollection services) { var provider = services.BuildServiceProvider(); var cfg = provider.GetService<ConfigModel>(); services.AddOidc(new Uri(cfg.SsoUri), (settings, siteUri) => { settings.UseDefaultCallbackUris(siteUri); settings.ClientId = "spaBlazorClient"; settings.ResponseType = "code"; settings.Scope = "openid profile email api"; settings.UseRedirectToCallerAfterAuthenticationRedirect(); settings.UseRedirectToCallerAfterLogoutRedirect(); settings.MinimumLogeLevel = Microsoft.Extensions.Logging.LogLevel.Information; settings.LoadUserInfo = true; settings.FilterProtocolClaims = true; settings.MonitorSession = true; settings.StorageType = Sotsera.Blazor.Oidc.Configuration.Model.StorageType.LocalStorage; }); } public void Configure(IComponentsApplicationBuilder app) { app.AddComponent<App>("app"); } } } 

Hauptkomponente von App.razor konfigurieren


App.blazor -Ändern Sie dies, damit autorisierte und nicht autorisierte Benutzer unterschiedlichen Text sehen und Routen aus der Bibliothek für oidc verbunden sind

 @using BlazorEShop.Shared.Presentation @using Microsoft.AspNetCore.Components @using Microsoft.Extensions.DependencyInjection @using Sotsera.Blazor.Oidc @inject IUserManager UserManager <Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(IUserManager).Assembly }"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <NotAuthorized> <h3>Sorry</h3> <p>You're not authorized to reach this page.</p> <p>You may need to log in as a different user.</p> </NotAuthorized> <Authorizing> <h3>Authentication in progress</h3> </Authorizing> </AuthorizeRouteView> </Found> <NotFound> <CascadingAuthenticationState> <LayoutView Layout="@typeof(MainLayout)"> <h3>Sorry</h3> <p>Sorry, there's nothing at this address.</p> </LayoutView> </CascadingAuthenticationState> </NotFound> </Router> 

Benutzer anmelden und abmelden


Die Benutzerverwaltung erfolgt über die IUserManager-Oberfläche. Es kann aus dem DI-Container bezogen werden. Zum Beispiel:

 @inject IUserManager UserManager @using Sotsera.Blazor.Oidc @using Microsoft.Extensions.DependencyInjection <AuthorizeView> <Authorized> <span class="login-display-name mr-3"> Hello, @context.User.Identity.Name! </span> <button type="button" class="btn btn-primary btn-sm" @onclick="LogoutRedirect"> Log out </button> </Authorized> <NotAuthorized> <button type="button" class="btn btn-primary btn-sm" @onclick="LoginRedirect"> Log in </button> </NotAuthorized> </AuthorizeView> @code { public async void LoginRedirect() => await UserManager.BeginAuthenticationAsync(p => p.WithRedirect()); public async void LogoutRedirect() => await UserManager.BeginLogoutAsync(p => p.WithRedirect()); } 

Anzeige verschiedener Informationen für autorisierte und nicht autorisierte Benutzer


Mit AuthorizeView können Sie jetzt in jedem Teil der Anwendung Bereiche angeben, die nur autorisierten Benutzern angezeigt werden. Sie können auch Rollen verwenden, um Benutzer anzugeben, mit welchen Rollen sie diesen Inhalt sehen können.

 <AuthorizeView Roles="admin, administrator"> <Authorized> <p>User Info</p> <p>@context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)</p> @foreach (var c in context.User.Claims) { <p>@c.Type : @c.Value : @string.Join(";", c.Properties.Select(x => $"{x.Key} : {x.Value}"))</p> } </Authorized> <NotAuthorized> <p>       admin   administrator</p> </NotAuthorized> </AuthorizeView> 

Zugriff auf bestimmte Seiten abhängig von Autorisierung und Benutzerrollen


Alles wird zum Standardattribut Autorisieren. Natürlich sollten Sie auch auf der Serverseite eine Überprüfung der Benutzerrechte durchführen.

Eine Seite, auf die nur ein autorisierter Benutzer mit Rollen zugreifen kann

 @page "/user" @attribute [Authorize] <h1>     </h1> 

Eine Seite, auf die nur ein autorisierter Benutzer mit der Administrator- oder Chefrolle zugreifen kann

 @page "/admin" @attribute [Authorize(Roles="admin, boss")] <h1>      admin  boss</h1> 

API-Aufruf


Verwenden Sie dazu OidcHttpClient, der aus dem DI-Container abgerufen werden kann. Es legt automatisch das Token des aktuellen Benutzers in der Anforderung ab. Zum Beispiel:

 @page "/fetchdata" @inject Sotsera.Blazor.Oidc.OidcHttpClient Http @inject BlazorEShop.Shared.Presentation.ConfigModel Config @using BlazorEShop.Shared.Presentation <h1>Weather forecast</h1> <p>This component demonstrates fetching data from the server.</p> @if (products == null) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Id</th> <th>Version</th> </tr> </thead> <tbody> @foreach (var product in products.Value) { <tr> <td>@product.Id</td> <td>@product.Version</td> </tr> } </tbody> </table> } @code { private PageResultModel<ProductModel> products; protected override async Task OnInitializedAsync() { var uri = Config.ApiUri; products = await Http.GetJsonAsync<PageResultModel<ProductModel>>($"{(uri.EndsWith('/') ? uri : uri + "/")}api/v1/products?take=100&skip;=0"); } } 

Source: https://habr.com/ru/post/de484596/


All Articles