
Привет, Хабр! Таки да, в прошлой своей статье я попробовал сделать Todo List на Blazor Wasm и остался доволен. Теперь я решил взяться за что-то по серьезней, чтобы опробовать его в деле. Буду делать простенький SPA UI на Blazor для простого вымышленного интернет магазина. Максимально приближенный к боевому применению вариант. Начну я с того что запилю авторизацию пользователей и разделения их по ролям т. е. чтобы админ и обычный пользователь видели немного разный интерфейс. Еще я это все в docker образы собрал и на docker registry выложил. За подробностями добро пожаловать под кат.
UPDATE
Мелкомягкие добавили возможность сразу создавать приложение для wasm с авторизацией и поддержкой PWA. Добавили новую библиотеку для авторизации. Все что описано в этой статье теперь делается намного проще.

Содержание
- Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо
- Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4
- Blazor Client Side Интернет Магазин: Часть 2 — CI/CD
Ссылки
Исходники
Образы на Docker Registry
Запуск
Нужно чтобы у вас уже был установлен докер с docker compose (тык) и подключен интернет потому что надо будет скачать мои образы.
Для того чтобы создать сертификаты, необходимые для работы микросервисов, установите .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://localhost:8000
https://localhost:8001
https://localhost:8002
и https://localhost:8003.
Библиотека для авторизации WASM клиента в браузере
Устанавливаем www.nuget.org/packages/Sotsera.Blazor.Oidc и радуемся жизни
Настройка Identity Server4
В общем я взял готовый и настроенный сервер просто добавил туда настройки для своего 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 hosted потому что мне так проще было настройки передать. Проще собрать докер образ. Можно и на 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;
}
// GET: api/<controller>
[HttpGet]
public ConfigModel Get()
{
return _configuration.Value;
}
}
}
На стороне клиента
Получаем настройки с сервера и добавляем их в наш 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, а попытка получить Result у 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 указать участки которые будут видеть только авторизованные пользователи. Можно также с помощью Roles указать пользователи с какими ролями могут видеть данный контент.
<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>
Доступ к определенным страницам в зависимости от авторизованности и ролей пользователя
Делается все стандартным атрибутом Authorize. Конечно же вам лучше сделать проверку прав пользователя и на стороне сервера тоже.
Страница на которую может заходить только авторизованный пользователь с любыми ролями
@page "/user"
@attribute [Authorize]
<h1>Вы авторизованный пользователь с любыми ролями</h1>
Страница на которую может заходить только авторизованный пользователь у которого есть роль admin или boss
@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");
}
}
