Toko Online Sisi Klien Blazor: Bagian 1 - Oidc otorisasi (oauth2) + Server Identitas4


Halo, Habr! Jadi ya, di artikel terakhir saya, saya mencoba membuat Daftar Todo di Blazor Wasm dan puas. Sekarang saya memutuskan untuk mengambil sesuatu dengan serius, untuk mencobanya. Saya akan membuat UI SPA sederhana di Blazor untuk toko online fiksi sederhana. Sedekat mungkin untuk memerangi opsi penggunaan. Untuk memulainya, saya akan mencatat otorisasi pengguna dan pemisahan mereka dengan peran, yaitu, sehingga administrator dan pengguna biasa melihat antarmuka yang sedikit berbeda. Saya juga mengumpulkan semua ini dalam gambar buruh pelabuhan dan mengunggahnya ke register buruh pelabuhan. Untuk detailnya, selamat datang di kucing.

Isi




Referensi


Kode sumber
Gambar Pendaftaran Docker

Luncurkan


Diperlukan bahwa Anda sudah memiliki buruh pelabuhan yang diinstal dengan susunan buruh pelabuhan ( tyk ) dan Internet terhubung karena Anda perlu mengunduh gambar saya.

Untuk membuat sertifikat yang diperlukan agar layanan microser berfungsi, instal .net core dan jalankan perintah ini di 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 


Untuk memulai proyek, Anda perlu mengunduh file docker-compose.yml (atau menyalin kontennya ke file dengan nama yang sama) dan menjalankan perintah docker-compose up di direktori tempat file ini berada. Layanan microser adalah mendengarkan alamat
https: // localhost: 8000
https: // localhost: 8001
https: // localhost: 8002
dan https: // localhost: 8003 .

Perpustakaan untuk mengotorisasi klien WASM di browser


Instal www.nuget.org/packages/Sotsera.Blazor.Oidc dan nikmati hidup

Konfigurasikan Identity Server4


Secara umum, saya mengambil server yang sudah jadi dan baru saja menambahkan pengaturan untuk klien SPA saya di sana. Menyiapkan Identity Server4 sendiri berada di luar cakupan artikel ini karena ini tentang Blazor. Jika Anda tertarik, Anda dapat melihat sumber saya.

Tambahkan klien kami ke daftar klien yang tersedia

  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 } 

Untuk mendapatkan lebih banyak peran dalam token JWT, kami menerapkan IProfileService kami

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

Di sini seluruh titik dalam potongan kode ini

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

dan tambahkan ke asp.net

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

Pembuatan proyek


Di sini saya memilih ASP.NET Core yang di-host karena sangat mudah bagi saya untuk mentransfer pengaturan. Lebih mudah untuk merakit gambar buruh pelabuhan. Anda juga dapat meng-host nginx jika diinginkan di dalam wadah.





Mentransfer pengaturan dari file konfigurasi dan variabel lingkungan


Sisi server


Tambahkan model pengaturan

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

Daftarkan di 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); } 

Lulus ke klien sebagai 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; } } } 

Sisi klien


Kami mendapatkan pengaturan dari server dan menambahkannya ke wadah DI kami

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

Karena Blazor Wasm tidak mendukung async void Main, dan upaya untuk mendapatkan Hasil dari Tugas menyebabkan kebuntuan karena kami hanya memiliki satu utas, kami harus membungkus semuanya dalam Task.Run (async () => {});

Mengaktifkan pustaka oidc (oauth2) di sisi klien


Kami memanggil services.AddOidc dengan pengaturan yang kami terima dari server di dalam 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"); } } } 

Mengkonfigurasi komponen utama App.razor


App.blazor -Ubah itu sehingga pengguna yang berwenang dan tidak sah melihat teks yang berbeda dan bahwa rute dari perpustakaan untuk oidc terhubung

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

Login dan keluar pengguna


Manajemen pengguna dilakukan melalui antarmuka IUserManager. Itu bisa didapat dari wadah DI. Sebagai contoh:

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

Tampilan berbagai informasi untuk pengguna resmi dan tidak resmi


Sekarang, di bagian mana pun dari aplikasi, menggunakan AuthorizeView, Anda dapat menentukan area yang hanya akan dilihat oleh pengguna yang berwenang. Anda juga dapat menggunakan Peran untuk menentukan pengguna dengan peran apa mereka dapat melihat konten ini.

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

Akses ke halaman tertentu tergantung pada otorisasi dan peran pengguna


Semuanya menjadi atribut standar Otorisasi. Tentu saja, Anda sebaiknya melakukan pemeriksaan hak pengguna di sisi server juga.

Halaman yang hanya dapat diakses oleh pengguna yang berwenang dengan peran apa pun

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

Halaman yang hanya dapat diakses oleh pengguna resmi yang memiliki peran admin atau bos

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

Panggilan API


Untuk melakukan ini, gunakan OidcHttpClient yang dapat diperoleh dari wadah DI. Secara otomatis meletakkan token pengguna saat ini dalam permintaan. Sebagai contoh:

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


All Articles