
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 fuenteImágenes de registro de DockerLanzamiento
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: 8000https: // localhost: 8001https: // localhost: 8002y
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; }
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"); } }