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

UPDATE


Мелкомягкие добавили возможность сразу создавать приложение для wasm с авторизацией и поддержкой PWA. Добавили новую библиотеку для авторизации. Все что описано в этой статье теперь делается намного проще.


Содержание




Ссылки


Исходники
Образы на 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");
    }
}