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

a85881c3aca07e1e9012e992f4a65a51

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

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

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

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

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

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

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

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

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

ApplicationUser

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

public abstract class ApplicationUser : IdentityUser, ICreatedAt, IKey
{
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public AccountStatus AccountStatus { get; set; } = AccountStatus.Inactive;
    [NotMapped] public abstract IdentityRole Role { get; }

    /// 
    /// Public name.
    /// 
    public string? Name { get; set; }
}

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

ApplicationObject

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

public abstract class ApplicationObject : ApplicationUser, IHasTitleImage, 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 ImageLinks { get; set; } = [];
}

Partner

public class Partner : ApplicationObject, IHasType
{
    [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
{
    [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 Subscriptions { get; set; } = [];
    public ICollection 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 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()
    .AddEntityFrameworkStores()
    .AddDefaultTokenProviders();
builder.Services.AddIdentityCore(opt =>
{
    opt.SignIn.RequireConfirmedEmail = true;
})
    .AddEntityFrameworkStores()
    .AddDefaultTokenProviders()
    .AddSignInManager>()
    .AddApiEndpoints()
    .AddClaimsPrincipalFactory>();
builder.Services.AddIdentityCore()
    .AddEntityFrameworkStores()
    .AddDefaultTokenProviders()
    .AddSignInManager>()
    .AddApiEndpoints()
    .AddClaimsPrincipalFactory>();
builder.Services.AddIdentityCore(opt =>
{
    opt.SignIn.RequireConfirmedEmail = true;
})
    .AddEntityFrameworkStores()
    .AddDefaultTokenProviders()
    .AddSignInManager>()
    .AddApiEndpoints()
    .AddClaimsPrincipalFactory>();
builder.Services.AddIdentityCore(opt =>
{
    opt.SignIn.RequireConfirmedEmail = true;
})
    .AddEntityFrameworkStores()
    .AddDefaultTokenProviders()
    .AddSignInManager>()
    .AddClaimsPrincipalFactory>();

// 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(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 : UserClaimsPrincipalFactory where TUser : ApplicationUser
{
    private readonly ILogger> _logger;

    public CustomUserClaimsPrincipalFactory(
        UserManager userManager,
        IOptions optionsAccessor,
        ILogger> logger)
        : base(userManager, optionsAccessor)
    {
        _logger = logger;
    }

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

© Habrahabr.ru