Start Debugging

Как удалить или скрыть отдельные endpoint, которые MapIdentityApi добавляет в ASP.NET Core

MapIdentityApi<TUser>() добавляет десять endpoint и не позволяет пропустить ни один из них. Вот четыре проверенных способа справиться с этим на .NET 11: скрыть endpoint из OpenAPI, заставить его возвращать 404, перекрыть его или полностью убрать из маршрутизации, плюс ловушка /confirmEmail, из-за которой регистрация превращается в 500.

Короткий ответ: у MapIdentityApi<TUser>() нет параметра для выбора endpoint, и в .NET 11 RC 2 это по-прежнему так. Метод возвращает IEndpointConventionBuilder, а его соглашения выполняются для каждого endpoint, когда итоговый шаблон маршрута уже известен, поэтому /register или /manage/2fa можно находить по шаблону. Чтобы скрыть endpoint только из документа OpenAPI, добавьте в соглашении ExcludeFromDescriptionAttribute. Чтобы сделать его непригодным для использования, дополнительно замените EndpointBuilder.RequestDelegate делегатом, который возвращает 404. Чтобы полностью убрать его из маршрутизации, подключите Identity к приватному IEndpointRouteBuilder и заново опубликуйте его endpoint через фильтрующий EndpointDataSource (около 30 строк, код ниже). Никогда не удаляйте /confirmEmail, пока работают /register или /resendConfirmationEmail: оба выбросят NotSupportedException и вернут 500.

Всё ниже запускалось на macOS с SDK 11.0.100-rc.1.26425.128, Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128 и хранилищем EF Core InMemory. Я сравнил IdentityApiEndpointRouteBuilderExtensions.cs между тегами v8.0.0, v11.0.0-rc.1.26425.128 и v11.0.0-rc.2.26504.105: список маршрутов во всех трёх одинаков, а между RC 1 и RC 2 файл не менялся, так что приёмы подходят для .NET 8, 9, 10 и 11.

Почему нет встроенного способа пропустить endpoint

MapIdentityApi<TUser>() это один метод расширения, который создаёт пустую группу маршрутов и добавляет в неё всё:

МетодМаршрутТребуется авторизация
POST/registerнет
POST/loginнет
POST/refreshнет
GET/confirmEmailнет
POST/resendConfirmationEmailнет
POST/forgotPasswordнет
POST/resetPasswordнет
POST/manage/2faда
GET/manage/infoда
POST/manage/infoда

Затем исходный код оборачивает группу в приватный тип IdentityEndpointsConventionBuilder с комментарием, что это сделано “to avoid a potential future behavioral breaking change”. Вернуть значение обратно к RouteGroupBuilder нельзя, а обработчики это лямбды внутри метода, поэтому и вызвать их самостоятельно тоже нельзя.

Запрос на разделение метода открыт с 2024 года: dotnet/aspnetcore#55792 (“Split MapIdentityApi() into multiple APIs and allow selective end points to be enabled”), а более общий dotnet/aspnetcore#50303 о расширяемости тянется со времён превью .NET 8. Оба находятся в milestone Backlog. Пока ни один из них не выйдет, все типичные причины, по которым с этим сталкиваются (публичная регистрация должна быть отключена, 2FA обрабатывается в другом месте, документ OpenAPI не должен рекламировать сброс пароля), приходится решать снаружи.

Тестовое приложение

Это приложение, в которое подключается каждый из вариантов ниже. Endpoint Identity находятся под /account, потому что так их подключает большинство реальных приложений и потому что это влияет на сопоставление маршрутов:

// .NET 11 RC 1, Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ids"));
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();
builder.Services.AddOpenApi();

var app = builder.Build();
app.MapOpenApi();

var account = app.MapGroup("/account");
account.MapIdentityApi<IdentityUser>();

app.Run();

class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<IdentityUser>(options);

Без изменений /openapi/v1.json перечисляет все десять маршрутов под /account, POST /account/register возвращает 200, а анонимный POST /account/manage/2fa возвращает 401.

Одна деталь важна для каждого приёма ниже: внутри соглашения RouteEndpointBuilder.RoutePattern.RawText это итоговый шаблон, включая префикс группы. Если вывести его в журнал из соглашения, получится /account/register, /account/manage/2fa и так далее, а не /register. Сам Identity опирается на это, когда задаёт имя endpoint подтверждения почты. Поэтому сопоставляйте через EndsWith или сравнивайте с полным путём с префиксом:

// .NET 11 RC 1
string[] blocked = ["/register", "/manage/2fa"];

bool IsBlocked(EndpointBuilder b) =>
    b is RouteEndpointBuilder rb &&
    blocked.Any(p => rb.RoutePattern.RawText!.EndsWith(p, StringComparison.OrdinalIgnoreCase));

Вариант 1: скрыть endpoint только из документа OpenAPI

Если endpoint должен продолжать работать, но не попадать в опубликованный контракт (обычное дело для /refresh или /manage/info в публичном документе API), добавьте те же метаданные, что добавляет .ExcludeFromDescription():

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (IsBlocked(b))
        b.Metadata.Add(new ExcludeFromDescriptionAttribute());
});

Измеренный результат: /account/register и /account/manage/2fa исчезают из /openapi/v1.json, а POST /account/register по-прежнему возвращает 200. В этом смысл варианта и его предел. Скрытие это документация, а не контроль доступа. Всякий, кто читал документацию по Identity, знает, что маршрут существует.

ExcludeFromDescriptionAttribute находится в Microsoft.AspNetCore.Routing и реализует IExcludeFromDescriptionMetadata, который учитывают и встроенный генератор Microsoft.AspNetCore.OpenApi, и API explorer в Swashbuckle. Если вы хотите держать всё в одном месте, ту же фильтрацию можно сделать в трансформере документа, как показано в статье о трансформерах операций и схем OpenAPI, но подход с метаданными проще и охватывает также других потребителей API explorer.

Вариант 2: оставить маршрут, но возвращать 404

Чтобы действительно отключить endpoint, замените его делегат запроса в том же соглашении. Соглашения получают EndpointBuilder до построения RouteEndpoint, а RequestDelegate это свойство с сеттером:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (!IsBlocked(b)) return;

    b.Metadata.Add(new ExcludeFromDescriptionAttribute());
    b.RequestDelegate = ctx =>
    {
        ctx.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.CompletedTask;
    };
});

Измеренный результат: POST /account/register возвращает 404, оба маршрута пропали из документа OpenAPI, а все остальные endpoint ведут себя как раньше. С .Finally(...) вместо .Add(...) результат такой же. Соглашения Finally выполняются после всех остальных, и это более безопасный выбор, если что-то ещё в вашем конвейере тоже трогает RequestDelegate.

Загвоздка в том, что с точки зрения маршрутизации и авторизации endpoint по-прежнему существует. Он сохраняет все свои метаданные, включая RequireAuthorization(), который Identity ставит на группу /manage. Middleware авторизации выполняется раньше делегата endpoint, поэтому анонимный POST /account/manage/2fa по-прежнему возвращает 401, а не 404. До вашего 404 доходят только аутентифицированные вызывающие. Если цель в том, чтобы не раскрывать само существование endpoint, эта разница видна снаружи.

К тому же результату приводит фильтр endpoint, потому что AddEndpointFilter работает с любым IEndpointConventionBuilder:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().AddEndpointFilter(async (ctx, next) =>
{
    var route = (ctx.HttpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText ?? "";
    if (blocked.Any(p => route.EndsWith(p))) return Results.NotFound();
    return await next(ctx);
});

Для /account/register он тоже вернул 404, но здесь он строго хуже: фильтр выполняется для каждого запроса Identity, endpoint остаются в документе OpenAPI, пока вы не добавите и метаданные, а привязка модели тела запроса к моменту запуска фильтра уже выполнена. Порядок выполнения подробнее разобран в сравнении фильтров endpoint и middleware. Используйте подмену делегата.

Вариант 3: перекрыть маршрут endpoint с более высоким приоритетом

Во многих ответах в интернете предлагают сопоставить собственный endpoint с тем же маршрутом. В наивном виде, account.MapPost("/register", () => Results.NotFound()) после MapIdentityApi, это ничего полезного не даёт: я ожидал AmbiguousMatchException, но JSON POST /account/register всё равно вернул 200 из обработчика Identity. Endpoint Identity привязывает параметр [FromBody], поэтому несёт метаданные accepts для application/json, а политика сопоставления по content-type ставит его выше вашего, который ничего не объявляет. Ваш endpoint молча никогда не выполняется. Задайте ему меньший Order, и сопоставитель предпочтёт его ещё до любого такого разрешения ничьей:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>();
account.MapPost("/register", () => Results.NotFound())
    .WithOrder(-1)
    .ExcludeFromDescription();

Измеренный результат: POST /account/register возвращает 404, а список endpoint теперь содержит и POST /account/register order=0, и POST /account/register order=-1. Исходный endpoint по-прежнему есть в документе OpenAPI, потому что ExcludeFromDescription() применяется только к вашему перекрывающему endpoint. Чтобы скрыть его, нужен вариант 1 сверху.

Перекрытие подходит, когда endpoint нужно заменить, а не удалить, например /register, который дополнительно требует код приглашения, пока остальной MapIdentityApi остаётся стандартным. Для простого удаления это самый неряшливый из четырёх вариантов.

Вариант 4: полностью убрать endpoint из маршрутизации

Чтобы endpoint действительно не существовал (ни маршрута, ни записи в OpenAPI, ни 401 для анонимных вызывающих, только тот же 404, что и для любого неизвестного URL), нужно не дать его RouteEndpoint попасть в систему маршрутизации. MapIdentityApi использует только два члена переданного ему IEndpointRouteBuilder: ServiceProvider для получения сервисов и DataSources, куда MapGroup("") добавляет свой источник endpoint. Это и даёт точку подключения:

  1. Передайте MapIdentityApi собственный небольшой IEndpointRouteBuilder, у которого коллекция DataSources приватная.
  2. Оберните эту приватную коллекцию в EndpointDataSource, который отбрасывает ненужные endpoint.
  3. Добавьте обёртку в DataSources настоящего построителя маршрутов.
// .NET 11 RC 1, works the same on .NET 8, 9 and 10
using Microsoft.Extensions.Primitives;

static class IdentityApiExceptExtensions
{
    public static IEndpointConventionBuilder MapIdentityApiExcept<TUser>(
        this IEndpointRouteBuilder endpoints, params string[] excludedRoutes)
        where TUser : class, new()
    {
        var capture = new CapturingRouteBuilder(endpoints);
        var conventions = capture.MapIdentityApi<TUser>();

        bool Keep(Endpoint e) =>
            e is not RouteEndpoint re ||
            !excludedRoutes.Any(r =>
                re.RoutePattern.RawText!.EndsWith(r, StringComparison.OrdinalIgnoreCase));

        endpoints.DataSources.Add(new FilteringEndpointDataSource(capture.DataSources, Keep));
        return conventions;
    }

    private sealed class CapturingRouteBuilder(IEndpointRouteBuilder inner) : IEndpointRouteBuilder
    {
        public ICollection<EndpointDataSource> DataSources { get; } = new List<EndpointDataSource>();
        public IServiceProvider ServiceProvider => inner.ServiceProvider;
        public IApplicationBuilder CreateApplicationBuilder() => inner.CreateApplicationBuilder();
    }

    private sealed class FilteringEndpointDataSource(
        ICollection<EndpointDataSource> sources, Func<Endpoint, bool> keep) : EndpointDataSource
    {
        private readonly CompositeEndpointDataSource _inner = new(sources);

        public override IReadOnlyList<Endpoint> Endpoints =>
            _inner.Endpoints.Where(keep).ToList();

        public override IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context) =>
            _inner.GetGroupedEndpoints(context).Where(keep).ToList();

        public override IChangeToken GetChangeToken() => _inner.GetChangeToken();
    }
}

Использование сводится к правке одной строки:

// .NET 11 RC 1
var account = app.MapGroup("/account");
account.MapIdentityApiExcept<IdentityUser>("/register", "/manage/2fa");

Измеренный результат: в списке endpoint восемь записей вместо десяти, /openapi/v1.json перечисляет восемь путей, а и POST /account/register, и анонимный POST /account/manage/2fa возвращают 404. Остальные восемь endpoint работают как раньше, а возвращаемый построитель соглашений по-прежнему применяется к ним, так что .RequireRateLimiting(...) или .WithTags(...), добавленные в цепочку, ведут себя как обычно.

В этом коде легко ошибиться в двух местах:

Это опирается на то, что MapIdentityApi использует только ServiceProvider и DataSources. Так обстоит дело во всех версиях от 8.0 до 11 RC 2, но это деталь реализации. Будущая версия, которая, скажем, вызовет CreateApplicationBuilder(), всё равно заработает благодаря делегированию выше. Версия, которая подключает endpoint к какому-то другому построителю, не заработает. Тест, проверяющий ожидаемый список маршрутов (дамп IEnumerable<EndpointDataSource>, которым пользовался я, занимает три строки), поймает это при обновлении.

Ловушка /confirmEmail

Единственный endpoint, который нельзя убрать в одиночку, это GET /confirmEmail. /register, /resendConfirmationEmail и ветка смены почты в POST /manage/info генерируют ссылку подтверждения по имени endpoint через LinkGenerator.GetUriByName. Имя назначается в соглашении Finally на /confirmEmail как MapIdentityApi-, за которым следует итоговый шаблон маршрута.

При варианте 4 и MapIdentityApiExcept<IdentityUser>("/confirmEmail") соглашение всё равно выполняется (endpoint строится, затем отфильтровывается), поэтому имя задано, но генератор ссылок не находит endpoint с таким именем. Регистрация после этого падает:

System.NotSupportedException: Could not find endpoint named 'MapIdentityApi-/account/confirmEmail'.

Измеренный результат: POST /account/register возвращает 500, как и POST /account/resendConfirmationEmail, а /login продолжает работать. Если подтверждение почты через этот API вам вообще не нужно, удаляйте /register и /resendConfirmationEmail вместе с /confirmEmail либо оставьте /confirmEmail в маршрутизации и только скройте его из OpenAPI вариантом 1.

У варианта 2 такой проблемы нет, потому что endpoint для генерации ссылок продолжает существовать. Он просто отвечает 404.

Какой вариант выбрать

Если вы уже дошли до замены нескольких endpoint, сначала сравните компромиссы в статье JWT и cookie-аутентификация в ASP.NET Core 11. Bearer-токены, которые выдаёт MapIdentityApi, это непрозрачные токены Data Protection, а не JWT, и нередко именно это настоящая причина, по которой команда перерастает эти endpoint.

Подводные камни

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад