Я расскажу, как реализовать аутентификацию с использованием как JWT, так и API-ключа на одном и том же endpoint в ASP.NET Core Web API. Совмещение этих схем аутентификации полезно, если вы хотите использовать токен JWT Bearer для аутентификации пользователей и API-ключ для аутентификации между сервисами.

Эта статья основана на моем предыдущей статье, в которой я уже рассмотрел аутентификацию с использованием API-ключа в ASP.NET Core. Если вам нужно подробное объяснение этой реализации, обратитесь к ней. Эта реализация была протестирована как в .NET 8, так и в .NET 9.

ASP.NET Core позволяет комбинировать обработчики аутентификации для поддержки нескольких схем, и я продемонстрирую, как это реализовать.

Реализация составного обработчика аутентификации

Для того чтобы включить аутентификацию как с использованием JWT, так и с использованием API-ключа на одном endpoint, нам нужен составной обработчик аутентификации. Этот обработчик определяет, какую схему аутентификации применить в зависимости от запроса. Сначала мы проверим, содержит ли запрос API-ключ; если нет, мы применим схему аутентификации с использованием JWT. Реализация показана ниже:

internal static class CompositeAuthenticationDefaults
{
    public const string AuthenticationScheme =
        $"Composite-{JwtBearerDefaults.AuthenticationScheme}-{ApiKeyAuthenticationDefaults.ApiKeyHeaderName}";
}

internal sealed class CompositeAuthenticationHandler(
    IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
    : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var scheme = GetAuthunticationScheme();
        return await Context.AuthenticateAsync(scheme);
    }

    protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        var scheme = GetAuthunticationScheme();
        await Context.ChallengeAsync(scheme);
    }

    protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
    {
        var scheme = GetAuthunticationScheme();
        await Context.ForbidAsync(scheme);
    }

    private bool IsApiKeyAuthScheme()
        => Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.ApiKeyHeaderName);

    private string GetAuthunticationScheme()
        => IsApiKeyAuthScheme()
            ? ApiKeyAuthenticationDefaults.AuthenticationScheme
            : JwtBearerDefaults.AuthenticationScheme;
}

Настройка составной схемы аутентификации

Далее нам нужно настроить наши службы аутентификации для использования как JWT, так и API-ключа в файле Program.cs:

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = CompositeAuthenticationDefaults.AuthenticationScheme;
    })
    .AddScheme<AuthenticationSchemeOptions, CompositeAuthenticationHandler>(CompositeAuthenticationDefaults.AuthenticationScheme, _ => { })
    .AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.Authority = "authority";
        options.MetadataAddress = "metadata-address"; // e.g. https://login.microsoftonline.com/your-tenant-id/v2.0/.well-known/openid-configuration
        options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = false,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

builder.Services.AddAuthorization();

В этом примере конфигурация JWT гарантирует, что токены выданы доверенным провайдером и проверяет ключевые аспекты безопасности, такие как срок действия и ключ подписи.

Чтобы добавить аутентификацию JWT в ваш проект, установите пакет Microsoft.AspNetCore.Authentication.JwtBearer из NuGet.

Применение схем аутентификации в контроллерах

Для защиты API с использованием обеих схем аутентификации примените атрибут [Authorize] следующим образом:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class SecureController : ControllerBase
{
    [HttpGet]
    public IActionResult GetSecureData()
    {
        return Ok(new { Message = "This is a secure endpoint." });
    }
}

Если вы хотите ограничить endpoint использованием только JWT схемы, укажите:

[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]

Аналогично для API Key:

[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)]

Ниже пример использования [Authorize] атрибута в minimal API:

app.MapGet("/api/secure", [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] () =>
{
    return new { Message = "This is a secure endpoint." };
}).RequireAuthorization();

Заключение

Реализовав свой составной обработчик аутентификации, мы можем без проблем поддерживать как JWT, так и аутентификацию с использованием API-ключа на одном и том же API endpoint. Этот подход предоставляет гибкость для сценариев, когда различные пользователи вашего API требуют разных методов аутентификации.

Если вы нашли это руководство полезным или у вас есть вопросы, не стесняйтесь оставить комментарий!

Дополнение в ответ на комментарий: Использование AuthorizationPolicyBuilder для установки политики по умолчанию, как в коде ниже:

builder.Services.AddAuthorizationBuilder()
    .SetDefaultPolicy(new AuthorizationPolicyBuilder(ApiKeyAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme)
        .RequireAuthenticatedUser()
        .Build());

не подходит, если вы хотите применять только одну схему аутентификации для конкретного API-эндпоинта, поскольку устанавливает политику авторизации по умолчанию, которая допускает аутентификацию либо через API-ключ, либо через JWT для всех защищенных эндпоинтов. Это делает невозможным ограничение эндпоинта только одной схемой (например, только JWT или только API-ключ). Это может непреднамеренно привести к несанкционированному доступу, если эндпоинт должен поддерживать только один метод аутентификации. Например, если эндпоинт предназначен для аутентификации через JWT, но по умолчанию также разрешает API-ключ, это может создать угрозу безопасности, позволяя API-ключу обходить более строгие проверки аутентификации.

Комментарии (10)


  1. withkittens
    04.02.2025 19:32

    Мне кажется, ваше решение сильно переусложнено. И есть несколько ошибок.

    Давайте сначала разберём ApiKeyAuthenticationHandler из первой части:

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!TryGetApiKey(out var apiKey, out var failureMessage))
        {
            return AuthenticateResult.Fail(failureMessage!);
        }

    Когда схеме аутентификации недостаточно данных (например, заголовок с API-ключом отсутствует вообще), она должна возвращать AuthenticationResult.NoResult(). Это нужно чтобы не заваливать аутентификацию целиком, а дать другим схемам возможность тоже отработать. О, это же как раз наш случай!

    AuthenticationResult.Fail(...) - это для случаев, когда схема уже однозначно определена, например, если в запросе передан нужный заголовок, но в нём нет ключа или он невалидный:

    ApiKeyAuthenticationHandler.cs
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Context.Request.Headers.TryGetValue("X-API-Key", out var apiKeyHeaderValue))
        {
            return Task.FromResult(AuthenticateResult.NoResult());
        }
    
        // Проверяем ключ...
        if (apiKeyHeaderValue != "14f259ea-1b55-4135-a335-b4d3d6a1030f")
        {
            return Task.FromResult(AuthenticateResult.Fail("Invalid API key"));
        }
        
        var principal = new ClaimsPrincipal(new ClaimsIdentity([/* claims go here... */], "ApiKey"));
        return Task.FromResult(AuthenticateResult.Success(new(principal, "ApiKey")));
    }

    Теперь посмотрим на CompositeAuthenticationHandler. Он не нужен :) Нужно настроить политику авторизации:

    builder.Services.AddAuthorization(options =>
    {
        options.DefaultPolicy = new AuthorizationPolicyBuilder("ApiKey", "Bearer")
            .RequireAuthenticatedUser()
            .Build();
    });

    Тогда сначала произойдёт попытка аутентифицироваться по ключу, а если не получилось, то по токену. Конечно же, атрибут [Authorize(AuthenticationSchemes = ...)] с указанием конкретных схем продолжит работать.


    1. AntonAntonov88 Автор
      04.02.2025 19:32

      Да вы правы, проверил, не поспотрел в сторону AuthorizationPolicyBuilder сразу потому что у меня Composite немного сложнее чем в статье, я добавляю WWW-Authenticate в ответ для каждой поддерживаемой схемы чтобы информировать пользователя о необходиости использовать ApiKey. Спасибо, статью уберу в архив, она тогда получается имеет мало смысла)


    1. AntonAntonov88 Автор
      04.02.2025 19:32

      Еще раз перепроверил:

      using Microsoft.AspNetCore.Authentication;
      using Microsoft.AspNetCore.Authentication.JwtBearer;
      using Microsoft.AspNetCore.Authorization;
      using Microsoft.IdentityModel.Tokens;
      using Test.Api.Auth;
      
      var builder = WebApplication.CreateBuilder(args);
      
      // Add services to the container.
      var configuration = builder.Configuration;
      
      builder.Services.AddAuthentication(options =>
          {
              options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
          })
          .AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
          .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
          {
              var authority = configuration["Jwt:Authority"];
              if (string.IsNullOrWhiteSpace(authority))
                  throw new Exception("Unable to load Jwt:Authority configuration.");
      
              var metadataAddress = configuration["Jwt:MetadataAddress"];
              if (string.IsNullOrWhiteSpace(metadataAddress))
                  throw new Exception("Unable to load Jwt:MetadataAddress configuration.");
      
              options.Authority = authority;
              options.MetadataAddress = metadataAddress;
              options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
      
              options.TokenValidationParameters = new TokenValidationParameters
              {
                  ValidateIssuer = true,
                  ValidateAudience = false,
                  ValidateLifetime = true,
                  ValidateIssuerSigningKey = true
              };
          });
      
      builder.Services.AddAuthorizationBuilder()
          .SetDefaultPolicy(new AuthorizationPolicyBuilder(ApiKeyAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme)
              .RequireAuthenticatedUser()
              .Build());
      
      builder.Services.AddScoped<IApiKeyValidator, ApiKeyValidator>();
      
      var app = builder.Build();
      
      // Configure the HTTP request pipeline.
      
      app.UseHttpsRedirection();
      
      app.MapGet("/api/secure", [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] () =>
      {
          return new { Message = "This is a secure endpoint." };
      }).RequireAuthorization();
      
      app.Run();
      

      И результат такой что я могу использовать ApiKey, т.е. Authorize атрибут становится бесполезен так как пропускает обе схемы все равно.



    1. AntonAntonov88 Автор
      04.02.2025 19:32

      The AuthorizationPolicyBuilder is used to build a policy that requires authentication using one of the provided authentication schemes.


    1. AntonAntonov88 Автор
      04.02.2025 19:32

      И на всякий случай советую проверить везде где вы это используете, так как, это может непреднамеренно привести к несанкционированному доступу, если API должна поддерживать только один метод аутентификации. Например, если конечная точка предназначена для аутентификации с помощью JWT, но по умолчанию также разрешает аутентификацию с использованием API-ключа, это может подвергнуть endpoint рискам безопасности, при которых API-ключ может обойти более строгие проверки аутентификации.


      1. withkittens
        04.02.2025 19:32

        Хм, да, вы правы. Если в [Authorize] не указывать Policy, берётся политика по умолчанию, а мы в ней уже разрешили оба метода аутентификации.

        Но тогда получается всё ещё проще: политику авторизации по умолчанию мы не трогаем (или по крайней мере не указываем методы аутентификации):

        builder.Services.AddAuthorization();

        и перечисляем требуемые методы в [Authorize]:

        // Метод аутентификации по умолчанию
        [Authorize]
        
        // Только ApiKey
        [Authorize(AuthenticationSchemes = "ApiKey")]
        
        // ApiKey или Bearer
        [Authorize(AuthenticationSchemes = "ApiKey,Bearer")]


        1. AntonAntonov88 Автор
          04.02.2025 19:32

          Т.е. там где указано как вы пишите

          // Только ApiKey[Authorize(AuthenticationSchemes = "ApiKey")]

          Но если политика по умолчанию Jwt, то все равно Jwt будет работать, что противоречит ожидаемому поведению, не так ли?


          1. AntonAntonov88 Автор
            04.02.2025 19:32

            Я пишу про проверки методов аутентификации а вы пытаетесь решить это с помощью политик авторизации.


            1. withkittens
              04.02.2025 19:32

              Верно - потому что пропускать или запрещать запросы - это ответственность авторизации, отдельного шага после аутентификации.

              Грубо говоря, аутентификация проверяет у пользователя документы, это может быть паспорт или водительские права, а авторизация решает, какие именно документы (уже проверенные к этому моменту) принимать - любые или, например, только паспорт.


          1. withkittens
            04.02.2025 19:32

            Тут есть странное (с моей точки зрения) поведение.

            Допустим, у вас метод аутентификации по умолчанию - Jwt. Если мы не меняли политику авторизации по умолчанию, она равна

            new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()

            У политик авторизации есть свойство AuthenticationSchemes, которое определяет, какие обработчики аутентификации будут запущены. По умолчанию оно пустое. Это значит, что политика будет вызывать обработчик аутентификации по умолчанию, т.е. Jwt.

            Неочевидно, что [Authorize(AuthenticationSchemes)] определяет методы аутентификации в дополнение политике по умолчанию.

            И вот что получается с разными вариантами:

            [Authorize]

            [] + [] = [] -> политика авторизации использует метод аутентификации по умолчанию - Jwt.

            [Authorize(AuthenticationSchemes = "ApiKey")]

            [] + ["ApiKey"] = ["ApiKey"] -> только метод ApiKey - потому что AuthenticationSchemes политики больше не пустое, поэтому метод Jwt не используется.

            А вот что происходит, если в политике авторизации указать методы аутентификации:

            builder.Services.AddAuthorization(options =>
            {
                options.DefaultPolicy = new AuthorizationPolicyBuilder("Jwt")
                    .RequireAuthenticatedUser()
                    .Build();
            });
            [Authorize]

            ["Jwt"] + [] = ["Jwt"] -> метод Jwt

            [Authorize(AuthenticationSchemes = "ApiKey")]

            ["Jwt"] + ["ApiKey"] = ["Jwt", "ApiKey"] -> методы Jwt или ApiKey.