Несколько месяцев назад я начал разрабатывать бэкэнд проекта на 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. Несмотря на существующие ограничения и некоторые компромиссы, мне таки удалось создать рабочую архитектуру.
Автор: IAmNotAndrey