Многопользовательская архитектура в ASP.NET: Опыт разработки

от автора

Несколько месяцев назад я начал разрабатывать бэкэнд проекта на ASP.NET API. Проект представлял собой сервис для бронирования отелей (Airbnb послужил основным референсом). Опыта работы с ASP.NET у меня было немного: многому пришлось обучаться в процессе, а решение некоторых проблем занимало часы, а то и дни.

В этой статье я поделюсь полезными наработками и постараюсь ответить на вопросы, которые мне самому было сложно найти в Интернете

Многопользовательность: разные модели для разных задач

Приложение поддерживает несколько типов пользователей. Для этого я создал отдельные модели, каждая из которых описывает свою роль:

  • Tourist – туристы, конечные пользователи, которые ищут жильё для бронирования. Они могут просматривать доступные варианты, бронировать номера и оставлять отзывы.

  • Partner – владельцы недвижимости, предоставляющие жильё для аренды.

  • Admin – администраторы с полным доступом к приложению.

  • TravelAgent – туристические агенты, организующие туры.

Реализация моделей

ApplicationUser

Это базовая для всего приложения модель пользователя. Наследуется от IdentityUser .

public abstract class ApplicationUser : IdentityUser, ICreatedAt, IKey<string> {     public DateTime CreatedAt { get; init; } = DateTime.UtcNow;     public AccountStatus AccountStatus { get; set; } = AccountStatus.Inactive;     [NotMapped] public abstract IdentityRole Role { get; }      /// <summary>     /// Public name.     /// </summary>     public string? Name { get; set; } }

Из примечательного можно отметить так-себе реализацию хранения роли Role, такое повторять точно не стоит, но и лучше я ничего на тот момент придумать не смог.

ApplicationObject

Модель для хранения общей логики Partner и TravelAgent

public abstract class ApplicationObject : ApplicationUser, IHasTitleImage<ObjectImageLink>, IPublicationStatus {     public string? Description { get; set; }      public string? Coordinates { get; set; }     public string? Address { get; set; }      public PublicationStatus PublicationStatus { get; set; } = PublicationStatus.Unpublished;      [NotMapped] public abstract bool IsPublished { get; }      [NotMapped] public ObjectImageLink? TitleImageLink => ImageLinks.FirstOrDefault(e => e.IsTitle);      // ===      public ICollection<ObjectImageLink> ImageLinks { get; set; } = []; }

Partner

public class Partner : ApplicationObject, IHasType<ObjectType> {     [NotMapped] public override IdentityRole Role => new(nameof(Partner));     [NotMapped] public override bool IsPublished => PublicationStatus == PublicationStatus.Published && AccountStatus == AccountStatus.Active;      // ===      public Guid? TypeId { get; set; }     public virtual ObjectType? Type { get; set; } = null!;      public Guid? CityId { get; set; }     public City? City { get; set; } = null!;      // Some missing code..      public override string ToString()     {         return $"{nameof(Partner)}_{Id}";     } }

TravelAgent

public class TravelAgent : ApplicationObject, ISubscriptionStore<TravelAgentSubscription> {     [NotMapped] public override IdentityRole Role => new(nameof(TravelAgent));      public string? WebsiteUrl { get; set; }      [NotMapped] public override bool IsPublished => PublicationStatus == PublicationStatus.Published && AccountStatus == AccountStatus.Active;      // ===      // Some missing code..      public ICollection<TravelAgentSubscription> Subscriptions { get; set; } = [];     public ICollection<Tour> Tours { get; set; } = [];      public override string ToString()     {         return $"{nameof(TravelAgent)}_{Id}";     } }

Tourist

public class Tourist : ApplicationUser {     [NotMapped] public override IdentityRole Role => new(nameof(Tourist));      public ICollection<Booking> Bookings { get; set; } = [];      // Some missing code.. }

Admin

public class Admin : ApplicationUser {     [NotMapped] public override IdentityRole Role => new(nameof(Admin)); }

Как модели пользователей внедрить в приложение?

Модели мы написали: архитектурно довольно чисто, с возможностью в будущем легко создать новые типы пользователей. Как теперь сделать их работающими в рамках ASP.NET? Идём в Program.cs и пишем там примерно следующее:

// Some missing code ..  // BUG: once you set `opt.SignIn.RequireConfirmedEmail` to ANY LAST `ApplicationUser` child here in `Program.cs` \ // all user's `RequireConfirmedEmail`-properties will be overwritten.  // User settings builder.Services.AddIdentity<ApplicationUser, IdentityRole>()     .AddEntityFrameworkStores<ApplicationContext>()     .AddDefaultTokenProviders(); builder.Services.AddIdentityCore<Partner>(opt => {     opt.SignIn.RequireConfirmedEmail = true; })     .AddEntityFrameworkStores<ApplicationContext>()     .AddDefaultTokenProviders()     .AddSignInManager<SignInManager<Partner>>()     .AddApiEndpoints()     .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory<Partner>>(); builder.Services.AddIdentityCore<Tourist>()     .AddEntityFrameworkStores<ApplicationContext>()     .AddDefaultTokenProviders()     .AddSignInManager<SignInManager<Tourist>>()     .AddApiEndpoints()     .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory<Tourist>>(); builder.Services.AddIdentityCore<TravelAgent>(opt => {     opt.SignIn.RequireConfirmedEmail = true; })     .AddEntityFrameworkStores<ApplicationContext>()     .AddDefaultTokenProviders()     .AddSignInManager<SignInManager<TravelAgent>>()     .AddApiEndpoints()     .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory<TravelAgent>>(); builder.Services.AddIdentityCore<Admin>(opt => {     opt.SignIn.RequireConfirmedEmail = true; })     .AddEntityFrameworkStores<ApplicationContext>()     .AddDefaultTokenProviders()     .AddSignInManager<SignInManager<Admin>>()     .AddClaimsPrincipalFactory<CustomUserClaimsPrincipalFactory<Admin>>();  // NOTE: The following code should be placed AFTER 'AddIdentity' method. builder.Services.ConfigureApplicationCookie(options => {     options.Cookie.Name = "Identity.Application";     options.ExpireTimeSpan = TimeSpan.FromDays(30);      options.Events.OnRedirectToLogin = context =>     {         context.Response.StatusCode = StatusCodes.Status401Unauthorized;         return Task.CompletedTask;     };     options.Events.OnRedirectToAccessDenied = context =>     {         context.Response.StatusCode = StatusCodes.Status403Forbidden;         return Task.CompletedTask;     }; });  var app = builder.Build();  // Some missing code..

Что примечательного здесь?

Проблемы с RequireConfirmedEmail

options.SignIn.RequireConfirmedEmail — свойство, которое требует, чтобы почта пользователя была подтверждена (IdentityUser.EmailConfirmed == true), иначе он не сможет авторизоваться.

Здесь я обнаружил следующую проблему: мы не сможем установить разные значения этого свойства для разных типов пользователей. Последнее определённое options.SignIn.RequireConfirmedEmail будет применено ко всем остальным типам пользователей. В нашем случае последним указывается:

builder.Services.AddIdentityCore<Admin>(opt => {     opt.SignIn.RequireConfirmedEmail = true; })

Если бы мы поставили здесь false , то все остальные свойства были бы переустановленными в false .

Решение данной проблемы я, к сожалению, так и не смог найти, однако для меня это оказалось не критичным: для пользователей, которым подтверждение email не требовалось, я просто по умолчанию ставил EmailConfirmed=true .

Настройка Cookies: AddIdentity и ConfigureApplicationCookie

Вызов AddIdentity автоматически подключает механизм аутентификации на основе Cookies. Таким образом порядок вызова методов AddIdentity и ConfigureApplicationCookie играет ключевую роль, что важно учитывать, так как происходит это неявно, под капотом.

Без учёта данного факта у меня возникло множество проблем при работе с Cookies: я не мог переопределить логику редиректа, устанавливать время жизни куков и пр.

Решение оказалось до боли простым: нужно было просто переместить вызов ConfigureApplicationCookie после AddIdentity .
(Я, конечно, слышал, что, например, порядок middleware в приложении критически важен, но про порядок сервисов никто не заикался, и это стало для меня неожиданностью).

CustomUserClaimsPrincipalFactory

CustomUserClaimsPrincipalFactory позволяет извлекать роли из свойства Role пользовательских моделей. Это даёт возможность использовать атрибут [Authorize] для проверки прав доступа.

public class CustomUserClaimsPrincipalFactory<TUser> : UserClaimsPrincipalFactory<TUser> where TUser : ApplicationUser {     private readonly ILogger<CustomUserClaimsPrincipalFactory<TUser>> _logger;      public CustomUserClaimsPrincipalFactory(         UserManager<TUser> userManager,         IOptions<IdentityOptions> optionsAccessor,         ILogger<CustomUserClaimsPrincipalFactory<TUser>> logger)         : base(userManager, optionsAccessor)     {         _logger = logger;     }      protected override async Task<ClaimsIdentity> GenerateClaimsAsync(TUser user)     {         ClaimsIdentity identity = await base.GenerateClaimsAsync(user);          // Add custom Claim based on `Role`.         identity.AddClaim(new Claim(ClaimTypes.Role, user.Role.Name!));         _logger.LogInformation("A Role Claim was added with value '{Name}' to '{User}'", user.Role.Name, user);          return identity;     } }

Заключение

В этой статье я постарался на конкретном примере показать, как реализовать многопользовательское приложение на ASP.NET. Несмотря на существующие ограничения и некоторые компромиссы, мне таки удалось создать рабочую архитектуру.


ссылка на оригинал статьи https://habr.com/ru/articles/872656/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *