Auth

API Keys аутентифікація в ASP.NET Core

Генерація, зберігання та валідація API ключів в ASP.NET Core Minimal API: кастомний AuthenticationHandler, хешування ключів, scopes, rate limiting, ротація та аудит.

API Keys аутентифікація в ASP.NET Core

Stripe, GitHub, OpenAI, Twilio — всі вони використовують API Keys як основний метод аутентифікації для своїх API. JWT — чудовий вибір для веб-додатків з користувачами, але для CLI-інструментів, сервіс-до-сервісних запитів або коли клієнту не потрібна ідентичність — API Key простіший, передбачуваніший та легший в управлінні.

1. Навіщо API Keys?

Порівняння з JWT

JWTAPI Key
СтанStateless (payload у токені)Stateful (lookup у БД)
Строк діїВбудований (exp)Керується вручну
ВідкликанняСкладно (потрібен blacklist)Тривіально (DELETE з БД)
Для когоКінцевий користувач (web/mobile)Сервіси, CLI, скрипти
ПередачаAuthorization: Bearer ...X-Api-Key: ... або query param
РотаціяПовторний логінНова пара ключів, плавна міграція
АудитПотрібна окрема системаБезпосередньо по ключу
ПростотаСкладніша (підписи, алгоритми)Проста (рядок у заголовку)

Типові сценарії для API Keys

  • Публічний API (платний або безкоштовний): GitHub API, Stripe, OpenAI.
  • Інтеграції між системами (B2B): ваш сервіс → CRM партнера.
  • CLI-інструменти та скрипти: автоматизація без інтерактивного логіну.
  • Webhook verification: перевірка, що запит надійшов від довіреного джерела.
  • Mobile apps SDK: прості ключі для офіційних SDK.

2. Безпечний дизайн API Keys

Структура ключа

Хороший API Key має дві частини: публічний префікс та секретна частина:

sk_live_secretkey12345
|______|_____|_______|
Тип    Env   Секретна частина (256 bit random)
  • Префікс типу (sk_ — secret key, pk_ — public key) — допомагає ідентифікувати тип.
  • Середовище (live_, test_) — публічно видима інформація.
  • Секретна частина — криптографічно стійкий випадковий рядок.

Це підхід Stripe. Переваги:

  1. Якщо ключ випадково потрапляє в лог/git — видно, що це API key.
  2. Github навіть сканує publis repositories на такі патерни.
  3. Префікс дозволяє зробити grep по кодовій базі.

Хешування: зберігати лише хеш

Критичне правило безпеки: після показу ключа користувачу один раз — зберігайте лише його хеш. Якщо ключ вкрадуть із БД — він марний без знання оригіналу.

[Генерація]           [Показати ОДИН РАЗ]    [Зберігати у БД]
sk_live_4f8a...  ──►  Користувачу            SHA256("sk_live_4f8a...")
                                             = "a3f9c2..."

3. Генерація та зберігання API Keys

Модель бази даних

Models/ApiKey.cs
public class ApiKey
{
    public Guid   Id          { get; set; } = Guid.NewGuid();
    public string UserId      { get; set; } = null!;
    public string Name        { get; set; } = null!;  // Опис: "Production", "CI/CD"
    public string KeyHash     { get; set; } = null!;  // SHA-256 хеш
    public string Prefix      { get; set; } = null!;  // Перші 8 символів для ідентифікації

    // Scopes — що дозволяє цей ключ
    public List<string> Scopes { get; set; } = [];

    // Метадані
    public DateTime  CreatedAt    { get; set; } = DateTime.UtcNow;
    public DateTime? LastUsedAt   { get; set; }
    public DateTime? ExpiresAt    { get; set; }  // null = не спливає
    public bool      IsRevoked    { get; set; } = false;
    public string?   LastUsedIp   { get; set; }

    // Навігаційна властивість
    public AppUser User { get; set; } = null!;
}
Data/AppDbContext.cs — додаємо ApiKeys
public DbSet<ApiKey> ApiKeys { get; set; }

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.Entity<ApiKey>(e =>
    {
        // Унікальний індекс на хеш — для швидкого lookup при кожному запиті
        e.HasIndex(k => k.KeyHash).IsUnique();

        // Scopes як JSON колонка
        e.Property(k => k.Scopes)
         .HasConversion(
             v => JsonSerializer.Serialize(v, JsonOptions),
             v => JsonSerializer.Deserialize<List<string>>(v, JsonOptions)!);

        e.HasOne(k => k.User)
         .WithMany()
         .HasForeignKey(k => k.UserId)
         .OnDelete(DeleteBehavior.Cascade);
    });
}

private static readonly JsonSerializerOptions JsonOptions = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

Сервіс генерації ключів

Services/ApiKeyService.cs
public class ApiKeyService
{
    private readonly AppDbContext _db;
    private const string Prefix = "sk_live_";

    public ApiKeyService(AppDbContext db)
        => _db = db;

    /// <summary>
    /// Генерує новий API key, зберігає хеш у БД та повертає
    /// повний ключ (показується користувачу ОДИН РАЗ).
    /// </summary>
    public async Task<CreateApiKeyResult> CreateAsync(
        string userId,
        string name,
        List<string> scopes,
        DateTime? expiresAt = null)
    {
        // 1. Генеруємо криптографічно стійкий випадковий ключ
        //    32 байти = 256 біт = достатня ентропія
        var randomBytes = RandomNumberGenerator.GetBytes(32);
        var keyBody     = Convert.ToBase64String(randomBytes)
            .Replace("+", "A")   // URL-safe символи
            .Replace("/", "B")
            .Replace("=", "");   // Без паддингу

        var fullKey = $"{Prefix}{keyBody}";

        // 2. Хешуємо для зберігання у БД
        var keyHash = ComputeHash(fullKey);

        // 3. Зберігаємо перші 8 символів (для UI — "який ключ активний?")
        var keyPrefix = fullKey[..Math.Min(12, fullKey.Length)];

        // 4. Зберігаємо у БД
        var apiKey = new ApiKey
        {
            UserId    = userId,
            Name      = name,
            KeyHash   = keyHash,
            Prefix    = keyPrefix,
            Scopes    = scopes,
            ExpiresAt = expiresAt
        };

        _db.ApiKeys.Add(apiKey);
        await _db.SaveChangesAsync();

        // 5. Повертаємо повний ключ — БІЛЬШЕ НІКОЛИ НЕ ПОКАЖЕМО
        return new CreateApiKeyResult(
            Id:      apiKey.Id,
            FullKey: fullKey,   // Показати ОДИН РАЗ у UI
            Prefix:  keyPrefix  // Для подальшої ідентифікації
        );
    }

    /// <summary>
    /// Знаходить ApiKey у БД по хешу (без розшифровки).
    /// </summary>
    public async Task<ApiKey?> ValidateAsync(string apiKey)
    {
        var hash = ComputeHash(apiKey);

        // Lookup по хешу — O(1) завдяки унікальному індексу
        var key = await _db.ApiKeys
            .Include(k => k.User)
            .Where(k => k.KeyHash == hash
                     && !k.IsRevoked
                     && (k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow))
            .FirstOrDefaultAsync();

        if (key is null) return null;

        // Оновлюємо LastUsedAt (асинхронно — не блокуємо відповідь)
        key.LastUsedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();

        return key;
    }

    public async Task RevokeAsync(Guid keyId, string userId)
    {
        var key = await _db.ApiKeys
            .FirstOrDefaultAsync(k => k.Id == keyId && k.UserId == userId);

        if (key is not null)
        {
            key.IsRevoked = true;
            await _db.SaveChangesAsync();
        }
    }

    private static string ComputeHash(string input)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(bytes).ToLower();
    }
}

public record CreateApiKeyResult(Guid Id, string FullKey, string Prefix);

4. Кастомний AuthenticationHandler

ASP.NET Core підтримує кастомні схеми аутентифікації через AuthenticationHandler<TOptions>. Це інтеграція API Keys у стандартний pipeline:

Security/ApiKeyAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
    public const string DefaultScheme = "ApiKey";
    public string HeaderName { get; set; } = "X-Api-Key";
    // Альтернативний спосіб передачі через query string (менш безпечно)
    public string? QueryParamName { get; set; } = null;
}

public class ApiKeyAuthenticationHandler
    : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private readonly ApiKeyService _apiKeyService;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ApiKeyService apiKeyService)
        : base(options, logger, encoder)
    {
        _apiKeyService = apiKeyService;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // 1. Витягуємо ключ із різних джерел
        string? apiKey = null;

        // Пріоритет 1: заголовок X-Api-Key
        if (Request.Headers.TryGetValue(
            Options.HeaderName, out var headerValue))
        {
            apiKey = headerValue.FirstOrDefault();
        }

        // Пріоритет 2: Authorization: ApiKey <key>
        if (apiKey is null &&
            Request.Headers.TryGetValue("Authorization", out var authHeader))
        {
            var header = authHeader.FirstOrDefault();
            if (header?.StartsWith("ApiKey ", StringComparison.OrdinalIgnoreCase) == true)
                apiKey = header["ApiKey ".Length..].Trim();
        }

        // Пріоритет 3: query param (не рекомендовано для production)
        if (apiKey is null && Options.QueryParamName is not null &&
            Request.Query.TryGetValue(Options.QueryParamName, out var queryValue))
        {
            apiKey = queryValue.FirstOrDefault();
            // Попередження: ключ у URL — це в логах!
            Logger.LogWarning(
                "API key passed via query string — avoid this in production");
        }

        if (string.IsNullOrEmpty(apiKey))
            return AuthenticateResult.NoResult(); // Схема не застосовна

        // 2. Валідуємо ключ
        var key = await _apiKeyService.ValidateAsync(apiKey);

        if (key is null)
        {
            Logger.LogWarning(
                "Invalid API key attempt from {IP}",
                Context.Connection.RemoteIpAddress);

            return AuthenticateResult.Fail("Invalid API key.");
        }

        // 3. Будуємо ClaimsPrincipal на основі ключа та користувача
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, key.UserId),
            new(ClaimTypes.Name,           key.User.UserName ?? key.UserId),
            new(ClaimTypes.Email,          key.User.Email ?? ""),
            new("api_key_id",              key.Id.ToString()),
            new("api_key_name",            key.Name),
        };

        // Додаємо Scopes як claims
        foreach (var scope in key.Scopes)
            claims.Add(new Claim("scope", scope));

        // Додаємо ролі користувача
        // (якщо потрібна рольова авторизація)
        var roles = key.User.Roles ?? [];
        foreach (var role in roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        var identity  = new ClaimsIdentity(
            claims, ApiKeyAuthenticationOptions.DefaultScheme);
        var principal = new ClaimsPrincipal(identity);
        var ticket    = new AuthenticationTicket(
            principal, ApiKeyAuthenticationOptions.DefaultScheme);

        Logger.LogDebug(
            "API key authenticated: {KeyName} for user {UserId}",
            key.Name, key.UserId);

        return AuthenticateResult.Success(ticket);
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        // При 401 — не робимо redirect, повертаємо JSON
        Response.StatusCode  = 401;
        Response.ContentType = "application/json";
        return Response.WriteAsync("""
            {"error": "Missing or invalid API key",
             "hint": "Include 'X-Api-Key: your_key' in the request headers"}
            """);
    }

    protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
    {
        Response.StatusCode  = 403;
        Response.ContentType = "application/json";
        return Response.WriteAsync("""
            {"error": "Insufficient permissions",
             "hint": "This API key doesn't have the required scope"}
            """);
    }
}

Реєстрація схеми

Program.cs — реєстрація API Key схеми
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseSqlite("Data Source=app.db"));

builder.Services.AddScoped<ApiKeyService>();

// Реєструємо схему аутентифікації
builder.Services
    .AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
        ApiKeyAuthenticationOptions.DefaultScheme,
        options =>
        {
            options.HeaderName     = "X-Api-Key";
            options.QueryParamName = "api_key"; // null щоб вимкнути
        });

builder.Services.AddAuthorization();

5. Scopes та авторизація

Не всі ключі повинні мати однакові права. Scopes дозволяють видавати ключі з обмеженими правами:

Security/ApiScopes.cs — константи ScoPes
public static class ApiScopes
{
    public const string ReadProducts  = "products:read";
    public const string WriteProducts = "products:write";
    public const string ReadOrders    = "orders:read";
    public const string WriteOrders   = "orders:write";
    public const string Admin         = "admin";

    public static readonly string[] All =
    [
        ReadProducts, WriteProducts,
        ReadOrders, WriteOrders,
        Admin
    ];
}
Program.cs — Policy на основі Scope
builder.Services.AddAuthorization(options =>
{
    // Policy для кожного scope
    options.AddPolicy("products:read", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scope", ApiScopes.ReadProducts));

    options.AddPolicy("products:write", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scope", ApiScopes.WriteProducts));

    options.AddPolicy("orders:read", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scope", ApiScopes.ReadOrders));

    options.AddPolicy("admin", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scope", ApiScopes.Admin));
});

// Застосування policies до ендпоінтів
app.MapGet("/api/products",
    () => Results.Ok(new[] { "Coffee", "Tea" }))
    .RequireAuthorization("products:read");

app.MapPost("/api/products",
    (CreateProductRequest req) => Results.Created("/api/products/1", req))
    .RequireAuthorization("products:write");

Кастомний Scope Authorization Handler

Для складнішої логіки (наприклад, OR між scopes):

Security/ScopeRequirement.cs
using Microsoft.AspNetCore.Authorization;

public class ScopeRequirement : IAuthorizationRequirement
{
    public string[] RequiredScopes { get; }
    public bool     RequireAll     { get; }  // true = AND, false = OR

    public ScopeRequirement(bool requireAll, params string[] scopes)
    {
        RequireAll     = requireAll;
        RequiredScopes = scopes;
    }
}

public class ScopeAuthorizationHandler
    : AuthorizationHandler<ScopeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ScopeRequirement requirement)
    {
        var userScopes = context.User
            .FindAll("scope")
            .Select(c => c.Value)
            .ToHashSet();

        bool granted = requirement.RequireAll
            ? requirement.RequiredScopes.All(s => userScopes.Contains(s))
            : requirement.RequiredScopes.Any(s => userScopes.Contains(s));

        if (granted)
            context.Succeed(requirement);

        return Task.CompletedTask;
    }
}

6. Ендпоінти управління ключами

Генерація нового ключа

POST /api/keys — створення ключа
app.MapPost("/api/keys",
    async (CreateApiKeyRequest req,
           HttpContext ctx,
           UserManager<AppUser> userManager,
           ApiKeyService apiKeyService) =>
{
    var user = await userManager.GetUserAsync(ctx.User);
    if (user is null) return Results.Unauthorized();

    // Валідація scopes: чи є вони в дозволеному списку?
    var invalidScopes = req.Scopes
        .Except(ApiScopes.All)
        .ToList();

    if (invalidScopes.Any())
        return Results.BadRequest(new
        {
            error         = "Invalid scopes",
            invalidScopes = invalidScopes,
            validScopes   = ApiScopes.All
        });

    var result = await apiKeyService.CreateAsync(
        user.Id, req.Name, req.Scopes, req.ExpiresAt);

    return Results.Ok(new
    {
        id      = result.Id,
        name    = req.Name,
        // Ключ показується ТІЛЬКИ ОДИН РАЗ
        apiKey  = result.FullKey,
        prefix  = result.Prefix,
        scopes  = req.Scopes,
        warning = "Save this key! It will not be shown again.",
        expiresAt = req.ExpiresAt
    });
}).RequireAuthorization();

record CreateApiKeyRequest(
    string Name,
    List<string> Scopes,
    DateTime? ExpiresAt = null);

Список ключів (без секретних значень)

GET /api/keys — список ключів
app.MapGet("/api/keys",
    async (HttpContext ctx,
           UserManager<AppUser> userManager,
           AppDbContext db) =>
{
    var user = await userManager.GetUserAsync(ctx.User);
    if (user is null) return Results.Unauthorized();

    var keys = await db.ApiKeys
        .Where(k => k.UserId == user.Id && !k.IsRevoked)
        .OrderByDescending(k => k.CreatedAt)
        .Select(k => new
        {
            k.Id,
            k.Name,
            k.Prefix,    // "sk_live_4f8..." — лише перші символи
            k.Scopes,
            k.CreatedAt,
            k.LastUsedAt,
            k.ExpiresAt,
            IsExpired = k.ExpiresAt.HasValue && k.ExpiresAt < DateTime.UtcNow
        })
        .ToListAsync();

    return Results.Ok(keys);
}).RequireAuthorization();

Відкликання ключа

DELETE /api/keys/{id} — відкликання
app.MapDelete("/api/keys/{id:guid}",
    async (Guid id,
           HttpContext ctx,
           UserManager<AppUser> userManager,
           ApiKeyService apiKeyService) =>
{
    var user = await userManager.GetUserAsync(ctx.User);
    if (user is null) return Results.Unauthorized();

    await apiKeyService.RevokeAsync(id, user.Id);

    return Results.Ok(new { message = "API key revoked successfully." });
}).RequireAuthorization();

7. Ротація ключів

Ротація — процес видачі нового ключа з одночасним інвалідуванням старого. Правильна ротація дозволяє клієнтам плавно перейти на новий ключ без простою.

POST /api/keys/{id}/rotate — ротація
app.MapPost("/api/keys/{id:guid}/rotate",
    async (Guid id,
           RotateKeyRequest req,
           HttpContext ctx,
           UserManager<AppUser> userManager,
           ApiKeyService apiKeyService,
           AppDbContext db) =>
{
    var user = await userManager.GetUserAsync(ctx.User);
    if (user is null) return Results.Unauthorized();

    // Знаходимо старий ключ
    var oldKey = await db.ApiKeys
        .FirstOrDefaultAsync(k => k.Id == id
                                && k.UserId == user.Id
                                && !k.IsRevoked);

    if (oldKey is null)
        return Results.NotFound(new { error = "API key not found." });

    // 1. Створюємо новий ключ з тими ж scopes
    var newKeyResult = await apiKeyService.CreateAsync(
        user.Id,
        $"{oldKey.Name} (rotated)",
        oldKey.Scopes,
        req.NewExpiresAt ?? oldKey.ExpiresAt);

    // 2. Встановлюємо grace period для старого ключа
    //    (клієнти можуть оновити конфіг поступово)
    if (req.GracePeriodHours > 0)
    {
        oldKey.ExpiresAt = DateTime.UtcNow.AddHours(req.GracePeriodHours);
        await db.SaveChangesAsync();
    }
    else
    {
        // Одразу відкликаємо
        await apiKeyService.RevokeAsync(oldKey.Id, user.Id);
    }

    return Results.Ok(new
    {
        newKey     = newKeyResult.FullKey,  // Показати ОДИН РАЗ
        newPrefix  = newKeyResult.Prefix,
        oldKeyId   = oldKey.Id,
        oldKeyExpiresAt = oldKey.ExpiresAt,
        message    = req.GracePeriodHours > 0
            ? $"Old key will expire in {req.GracePeriodHours} hours. Update your config!"
            : "Old key immediately revoked. Update your config!"
    });
}).RequireAuthorization();

record RotateKeyRequest(int GracePeriodHours = 24, DateTime? NewExpiresAt = null);

8. Аудит та моніторинг

Кожен запит з API Key має залишати слід — хто, коли, звідки, з яким ключем:

Middleware/ApiKeyAuditMiddleware.cs
public class ApiKeyAuditMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ApiKeyAuditMiddleware> _logger;

    public ApiKeyAuditMiddleware(
        RequestDelegate next,
        ILogger<ApiKeyAuditMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        await _next(ctx);

        // Логуємо лише запити, аутентифіковані через API Key
        if (ctx.User.Identity?.IsAuthenticated == true &&
            ctx.User.Identity.AuthenticationType ==
                ApiKeyAuthenticationOptions.DefaultScheme)
        {
            var keyId   = ctx.User.FindFirst("api_key_id")?.Value;
            var keyName = ctx.User.FindFirst("api_key_name")?.Value;
            var userId  = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            var ip      = ctx.Connection.RemoteIpAddress?.ToString();

            _logger.LogInformation(
                "API Key request: " +
                "KeyId={KeyId} KeyName={KeyName} UserId={UserId} " +
                "IP={IP} Method={Method} Path={Path} Status={Status}",
                keyId, keyName, userId,
                ip, ctx.Request.Method,
                ctx.Request.Path, ctx.Response.StatusCode);
        }
    }
}
Program.cs — реєстрація middleware
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ApiKeyAuditMiddleware>(); // Після Auth

9. Обмеження за ключем (Rate Limiting)

Program.cs — Rate Limiting per API Key
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("api-key-policy",
        ctx =>
        {
            // Ключ для партиції — ідентифікатор ключа або IP
            var keyId = ctx.User.IsAuthenticated
                ? ctx.User.FindFirst("api_key_id")?.Value
                : ctx.Connection.RemoteIpAddress?.ToString();

            return RateLimitPartition.GetSlidingWindowLimiter(
                partitionKey: keyId ?? "anonymous",
                factory: _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit         = 100,           // 100 запитів
                    Window              = TimeSpan.FromMinutes(1), // за 1 хвилину
                    SegmentsPerWindow   = 4,             // Точність вікна
                    QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                    QueueLimit          = 0
                });
        });

    // Відповідь при перевищенні
    options.OnRejected = async (ctx, token) =>
    {
        ctx.HttpContext.Response.StatusCode  = 429;
        ctx.HttpContext.Response.ContentType = "application/json";

        await ctx.HttpContext.Response.WriteAsync("""
            {
                "error": "Rate limit exceeded",
                "retryAfter": 60,
                "message": "Too many requests. Please slow down."
            }
            """, token);
    };
});

app.UseRateLimiter();

// Застосування до ендпоінтів
app.MapGet("/api/products", ...)
    .RequireAuthorization()
    .RequireRateLimiting("api-key-policy");

10. Webhook Signature Verification

Webhook — HTTP-запит від зовнішнього сервісу (GitHub, Stripe) до вашого API. Потрібно перевірити, що запит справді прийшов від довіреного джерела.

Stripe використовує схему: HMAC-SHA256(payload, secret). Ось реалізація:

Middleware/WebhookSignatureMiddleware.cs
public class WebhookSignatureMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration  _config;

    public WebhookSignatureMiddleware(
        RequestDelegate next, IConfiguration config)
    {
        _next = next; _config = config;
    }

    public async Task InvokeAsync(HttpContext ctx)
    {
        if (!ctx.Request.Path.StartsWithSegments("/webhooks"))
        {
            await _next(ctx);
            return;
        }

        // Читаємо body
        ctx.Request.EnableBuffering();
        var body = await new StreamReader(ctx.Request.Body).ReadToEndAsync();
        ctx.Request.Body.Position = 0;

        // Верифікуємо підпис (Stripe-style: t=timestamp,v1=hash)
        var signature = ctx.Request.Headers["Stripe-Signature"].FirstOrDefault();
        var secret    = _config["Webhooks:StripeSecret"]!;

        if (!VerifyStripeSignature(body, signature, secret, out var error))
        {
            ctx.Response.StatusCode = 401;
            await ctx.Response.WriteAsync($"{{\"error\":\"{error}\"}}");
            return;
        }

        await _next(ctx);
    }

    private static bool VerifyStripeSignature(
        string payload, string? signature,
        string secret, out string error)
    {
        error = "";
        if (string.IsNullOrEmpty(signature))
        {
            error = "Missing Stripe-Signature header";
            return false;
        }

        // Парсимо: "t=1234567890,v1=abc123..."
        var parts     = signature.Split(',');
        var timestamp = parts.FirstOrDefault(p => p.StartsWith("t="))?[2..];
        var v1Hash    = parts.FirstOrDefault(p => p.StartsWith("v1="))?[3..];

        if (timestamp is null || v1Hash is null)
        {
            error = "Invalid signature format";
            return false;
        }

        // Захист від Replay Attack: timestamp не старше 5 хвилин
        if (!long.TryParse(timestamp, out var ts) ||
            DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts > 300)
        {
            error = "Signature expired";
            return false;
        }

        // Обчислюємо очікуваний хеш
        var expectedPayload = $"{timestamp}.{payload}";
        var key             = Encoding.UTF8.GetBytes(secret);
        var data            = Encoding.UTF8.GetBytes(expectedPayload);
        var hash            = HMACSHA256.HashData(key, data);
        var expectedHash    = Convert.ToHexString(hash).ToLower();

        // Тайм-постійне порівняння
        if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expectedHash),
            Encoding.UTF8.GetBytes(v1Hash)))
        {
            error = "Signature mismatch";
            return false;
        }

        return true;
    }
}

11. Тестування API Key аутентифікації

Tests/ApiKeyAuthTests.cs
public class ApiKeyAuthTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public ApiKeyAuthTests(WebApplicationFactory<Program> factory)
        => _factory = factory;

    [Fact]
    public async Task Request_WithValidApiKey_Returns200()
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Add("X-Api-Key", "sk_test_secretkey12345");

        var response = await client.GetAsync("/api/products");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task Request_WithoutApiKey_Returns401()
    {
        var client   = _factory.CreateClient();
        var response = await client.GetAsync("/api/products");

        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);

        var body = await response.Content.ReadAsStringAsync();
        body.Should().Contain("Missing or invalid API key");
    }

    [Fact]
    public async Task Request_WithInvalidScope_Returns403()
    {
        // Ключ існує, але немає scope "products:write"
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Add(
            "X-Api-Key", "sk_test_secretkey12345");

        var response = await client.PostAsJsonAsync(
            "/api/products", new { Name = "Coffee" });

        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }
}

12. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування

Рівень 3: Архітектура


13. Резюме

Хешуй, не зберігай

SHA-256 від повного ключа → у БД. Показуй ключ ОДИН РАЗ. Навіть якщо БД вкрадуть — ключі марні.

AuthenticationHandler

AuthenticationHandler<TOptions> — стандартний спосіб додати кастомну схему. Читає X-Api-Key, валідує, будує ClaimsPrincipal.

Scopes = fine-grained permissions

products:read, orders:write, admin. Claims у Principal, Policy через RequireClaim("scope", value).

Ротація без простою

Grace period: новий ключ + старий дійсний ще X годин. Клієнт має час оновити конфіг.

Далі: у наступній статті — Rate Limiting та Throttling у ASP.NET Core: вбудований RateLimiter, sliding window, token bucket, per-user та per-IP стратегії.

Copyright © 2026