Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4


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

    Содержание




    Ссылки


    Исходники
    Образы на 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");
        }
    }
    
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 3

      0
      Самый главный вопрос. Сколько это весит на стороне клиента?
        +1
        Тут пока только стартовая страница есть. Когда до делаю до конца тогда померю. Вообще можете скачать docker-compose.yml и запустить у себя всю систему чтобы потестить.
        Ну для того что есть сейчас — Angular в релизной версии по https — время готовности 493 ms и передано 829 Килобайт
        Blazor в релизной верси по  https — время готовности 1510 ms и передано 3858 Килобайт
        НО если подключить к Angular какой нибудь Kendo UI и сделать несколько страниц то он внезапно вырастет до нескольких мегабайт. Когда доделаю тогда видно будет. Да и тут главное время загрузки. Пользователь там редко мегабайты смотрит и разница между 500 и 1500 ms на глаз не сильно заметно. Да и вообще, это первая загрузка, пока файлы не закешированы. Во второй раз Blazor всего 202 Килобайта передает.
        +1
        Чтобы запустить проект, нужно скачать только файл docker-compose.yml и выполнить команду docker-compose up в той директории, где находиться этот файл. Еще конечно нужно чтобы у вас уже был установлен докер на компе и чтобы было подключения к интернету потому что ему надо будет скачать мои образы. Микросервисы слушают адреса https://localhost:8000 https://localhost:8001 https://localhost:8002 и https://localhost:8003.
        Для того чтобы создать сертификаты, необходимые для работы микросервисов, выполните данные команды в терминале Windows. Необходимо установить net core перед этим.

        Прямая последовательность действий смотрелась бы куда логичнее, чем "выполните команды, но перед этим поставьте докер и net core".

        Only users with full accounts can post comments. Log in, please.