
مرحبا يا هبر! نعم ، في مقالتي الأخيرة ، حاولت أن أجعل قائمة Todo على Blazor Wasm وكنت راضيًا عنها. الآن قررت أن تأخذ شيئا على محمل الجد ، من أجل تجربته. سأصنع واجهة مستخدم SPA بسيطة على Blazor لمتجر خيالي بسيط على الإنترنت. أقرب ما يمكن لمكافحة خيار الاستخدام. للبدء ، سأسجل إذن المستخدمين وفصلهم حسب الأدوار ، أي ، بحيث يرى المسؤول والمستخدم العادي واجهة مختلفة قليلاً. كما جمعت كل هذا في صور عامل ميناء وتحميله إلى سجل عامل ميناء. لمزيد من التفاصيل ، مرحبا بكم في القط.
محتوى
مراجع
شفرة المصدرصور عامل الميناء التسجيلإطلاق
من الضروري أن يكون لديك عامل تثبيت مثبت بالفعل باستخدام عامل إنشاء (
tyk ) وأن الإنترنت متصل لأنك ستحتاج إلى تنزيل صوري.
من أجل إنشاء الشهادات المطلوبة لتشغيل خدمات micros ، قم بتثبيت
.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 في الدليل الذي يوجد به هذا الملف. Microservices تستمع عناوين
https: // localhost: 8000https: // localhost: 8001https: // localhost: 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 المستضافة لأنه كان من الأسهل بالنسبة لي نقل الإعدادات. أسهل لتجميع صورة عامل ميناء. يمكنك أيضًا استضافة 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 لا يدعم async void Main ، ومحاولة الحصول على نتيجة من Task تؤدي إلى طريق مسدود لأن لدينا مؤشر ترابط واحد فقط ، كان علينا التفاف كل شيء في Task.Run (async () => {})؛
تفعيل مكتبة oidc (oauth2) على جانب العميل
نحن ندعو services.AddOidc مع الإعدادات التي تلقيناها من الخادم داخل 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"); } } }
تكوين المكون الرئيسي من 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
للقيام بذلك ، استخدم OidcHttpClient التي يمكن الحصول عليها من حاوية DI. يقوم تلقائيًا بوضع الرمز المميز للمستخدم الحالي في الطلب. على سبيل المثال:
@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"); } }