Несколько месяцев назад я начал разрабатывать бэкэнд проекта на 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/
Добавить комментарий