Blazor Client Side Online Store: Parte 1 - Autorización oidc (oauth2) + Identity Server4


Hola Habr! Entonces sí, en mi último artículo intenté hacer Todo List en Blazor Wasm y quedé satisfecho. Ahora decidí tomarme algo en serio para probarlo. Haré una simple interfaz de usuario de SPA en Blazor para una simple tienda ficticia en línea. Opción de uso lo más cerca posible para combatir. Para comenzar, registraré la autorización de los usuarios y su separación por roles, es decir, para que el administrador y el usuario común vean una interfaz ligeramente diferente. También recopilé todo esto en imágenes de docker y lo cargué en el registro de docker. Para más detalles, bienvenido a cat.

Contenido




Referencias


Código fuente
Imágenes de registro de Docker

Lanzamiento


Es necesario que ya tenga docker instalado con docker compose ( tyk ) e Internet esté conectado porque necesitará descargar mis imágenes.

Para crear los certificados necesarios para que funcionen los microservicios, instale .net core y ejecute estos comandos en Windows PowerShell.

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 


Para iniciar un proyecto, debe descargar el archivo docker-compose.yml (o copiar su contenido a un archivo con el mismo nombre) y ejecutar el comando docker-compose up en el directorio donde se encuentra este archivo. Los microservicios son direcciones de escucha
https: // localhost: 8000
https: // localhost: 8001
https: // localhost: 8002
y https: // localhost: 8003 .

Biblioteca para autorizar un cliente WASM en un navegador


Instale www.nuget.org/packages/Sotsera.Blazor.Oidc y disfrute de la vida

Configurar Identity Server4


En general, tomé un servidor listo y simplemente agregué la configuración de mi cliente SPA allí. La configuración de Identity Server4 está más allá del alcance de este artículo porque se trata de Blazor. Si está interesado, puede consultar mis fuentes.

Agregue nuestro cliente a la lista de clientes disponibles

  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 } 

Para obtener más roles en el token JWT, implementamos nuestro 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; } } } 

Aquí el punto completo de este código

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

y agregarlo a asp.net

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

Creación de proyectos


Aquí elegí ASP.NET Core alojado porque era más fácil para mí transferir la configuración. Más fácil de armar una imagen acoplable. También puede alojar nginx si lo desea dentro del contenedor.





Transferencia de configuraciones desde el archivo de configuración y las variables de entorno


Lado del servidor


Agregar modelo de configuración

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

Regístralo en 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); } 

Pase al cliente como 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; } } } 

Lado del cliente


Obtenemos la configuración del servidor y la agregamos a nuestro contenedor DI

 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; } } } 

Dado que Blazor Wasm no admite async void Main, y un intento de obtener Result from Task lleva al punto muerto porque solo tenemos un hilo, tuvimos que envolver todo en Task.Run (async () => {});

Activar la biblioteca oidc (oauth2) en el lado del cliente


Llamamos services.AddOidc con la configuración que recibimos del servidor dentro de ConfigModel.

 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"); } } } 

Configurar el componente principal de App.razor


App.blazor: cámbielo para que los usuarios autorizados y no autorizados vean texto diferente y que las rutas de la biblioteca para oidc estén conectadas

 @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> 

Inicio y cierre de sesión de usuario


La gestión de usuarios se lleva a cabo a través de la interfaz IUserManager. Se puede obtener del contenedor DI. Por ejemplo:

 @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()); } 

Visualización de diversa información para usuarios autorizados y no autorizados.


Ahora, en cualquier parte de la aplicación, utilizando AuthorizeView, puede especificar áreas que solo verán los usuarios autorizados. También puede usar Roles para especificar a los usuarios con qué roles pueden ver este contenido.

 <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> 

Acceso a páginas específicas según la autorización y los roles de usuario.


Todo se convierte en el atributo estándar Autorizar. Por supuesto, es mejor que también verifique los derechos del usuario en el lado del servidor.

Una página a la que solo puede acceder un usuario autorizado con roles

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

Una página a la que solo puede acceder un usuario autorizado que tenga el rol de administrador o jefe

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

Llamada API


Para hacer esto, use OidcHttpClient que se puede obtener del contenedor DI. Coloca automáticamente el token del usuario actual en la solicitud. Por ejemplo:

 @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/484596/


All Articles