
哈Ha! 所以是的,在我的上一篇文章中,我尝试制作《 Blazor Wasm》上的“待办事项清单”,并感到满意。 现在,我决定认真对待一些东西,以进行尝试。 我将在Blazor上创建一个简单的SPA UI,用于一个简单的虚构在线商店。 尽可能接近战斗使用选项。 首先,我将记录用户的授权及其按角色的分离,即管理员和普通用户看到的界面略有不同。 我也将所有这些收集在Docker映像中,并将其上传到Docker注册表。 有关详细信息,欢迎猫。
目录内容
参考文献
源代码Docker注册表映像发射
必须已经安装了
docker compose(
tyk )的
docker,并且已连接Internet,因为您需要下载我的映像。
为了创建微服务正常工作所需的证书,请安装
.net core并在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
要启动项目,您需要下载
docker-compose.yml文件 (或将其内容复制到具有相同名称的文件中),然后在该文件所在的目录中运行docker-compose up命令。 微服务是监听地址
https://本地主机:8000https://本地主机:8001https://本地主机:8002和
https:// localhost:8003 。
用于在浏览器中授权WASM客户端的库
安装
www.nuget.org/packages/Sotsera.Blazor.Oidc享受生活
配置身份服务器4
通常,我使用现成的服务器,然后在其中添加SPA客户端的设置。 设置Identity Server4本身不在本文讨论范围之内,因为它与Blazor有关。 如果您有兴趣,可以查看我的资料。
将我们的客户添加到可用客户列表中
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 }
为了在JWT令牌中获得更多角色,我们实现了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; } } }
这是这段代码的重点
var roles = await _userManager.GetRolesAsync(user); foreach (var role in roles) { context.IssuedClaims.Add(new Claim(JwtClaimTypes.Role, role)); }
并将其添加到asp.net
services.AddIdentityServer().AddProfileService<ProfileService>()
项目创建
在这里,我选择了ASP.NET Core托管,因为它使我更容易传输设置。 组装docker映像更容易。 如果需要,您还可以在容器内托管nginx。


从配置文件和环境变量传输设置
服务器端
添加设置模型
public class ConfigModel { public string SsoUri { get; set; } = string.Empty; public string ApiUri { get; set; } = string.Empty; }
在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); }
传递给客户端作为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; }
客户端
我们从服务器获取设置并将其添加到我们的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; } } }
由于Blazor Wasm不支持异步void Main,并且由于只有一个线程,因此尝试从Task获取结果会导致死锁,因此我们必须将所有内容包装在Task.Run(async()=> {});中。
在客户端激活oidc(oauth2)库
我们使用在ConfigModel中从服务器接收到的设置来调用services.AddOidc。
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"); } } }
配置App.razor的主要组件
App.blazor-对其进行更改,以使授权用户和未授权用户看到不同的文本,并连接来自oidc库的路由
@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>
用户登录和注销
用户管理通过IUserManager界面进行。 可以从DI容器中获得。 例如:
@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()); }
显示授权和非授权用户的各种信息
现在,在应用程序的任何部分中,使用AuthorizeView,您可以指定只有授权用户才能看到的区域。 您还可以使用“角色”来指定用户可以使用哪些角色查看此内容。
<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>
根据授权和用户角色访问特定页面
一切都成为标准属性“授权”。 当然,您最好也在服务器端进行用户权限检查。
只能由具有任何角色的授权用户访问的页面
@page "/user" @attribute [Authorize] <h1> </h1>
仅具有管理员或老板角色的授权用户可以访问的页面
@page "/admin" @attribute [Authorize(Roles="admin, boss")] <h1> admin boss</h1>
API调用
为此,请使用可以从DI容器获得的OidcHttpClient。 它会自动将当前用户的令牌放入请求中。 例如:
@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"); } }