Pull to refresh

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

Reading time11 min
Views11K

Привет, Хабр! Таки да, в прошлой своей статье я попробовал сделать 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");
    }
}
Tags:
Hubs:
+1
Comments3

Articles