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

в 6:16, , рубрики: AddIdentity, authentication, ConfigureApplicationCookie, cookies, multiple identity users

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js