API Keys аутентифікація в ASP.NET Core
API Keys аутентифікація в ASP.NET Core
1. Навіщо API Keys?
Порівняння з JWT
| JWT | API 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. Переваги:
- Якщо ключ випадково потрапляє в лог/git — видно, що це API key.
- Github навіть сканує publis repositories на такі патерни.
- Префікс дозволяє зробити grep по кодовій базі.
Хешування: зберігати лише хеш
Критичне правило безпеки: після показу ключа користувачу один раз — зберігайте лише його хеш. Якщо ключ вкрадуть із БД — він марний без знання оригіналу.
[Генерація] [Показати ОДИН РАЗ] [Зберігати у БД]
sk_live_4f8a... ──► Користувачу SHA256("sk_live_4f8a...")
= "a3f9c2..."
3. Генерація та зберігання API Keys
Модель бази даних
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!;
}
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
};
Сервіс генерації ключів
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:
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"}
""");
}
}
Реєстрація схеми
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 дозволяють видавати ключі з обмеженими правами:
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
];
}
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):
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. Ендпоінти управління ключами
Генерація нового ключа
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);
Список ключів (без секретних значень)
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();
Відкликання ключа
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. Ротація ключів
Ротація — процес видачі нового ключа з одночасним інвалідуванням старого. Правильна ротація дозволяє клієнтам плавно перейти на новий ключ без простою.
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 має залишати слід — хто, коли, звідки, з яким ключем:
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);
}
}
}
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ApiKeyAuditMiddleware>(); // Після Auth
9. Обмеження за ключем (Rate Limiting)
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). Ось реалізація:
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 аутентифікації
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: Базовий
Реалізуйте сервіс генерації та валідації ключів:
CreateAsync(userId, name, scopes)— генерує ключ з префіксомsk_test_- Зберігає лише SHA-256 хеш та перші 12 символів (prefix)
ValidateAsync(apiKey)— lookup по хешу, перевіряєIsRevokedтаExpiresAtRevokeAsync(keyId, userId)— встановлюєIsRevoked = true- Напишіть unit-тест: той самий ключ → той самий хеш → знаходить запис
Реалізуйте ApiKeyAuthenticationHandler:
- Читає ключ з заголовка
X-Api-Key - Викликає
ApiKeyService.ValidateAsync - При успіху — будує
ClaimsPrincipalзsub,email,api_key_id, scopes - При відсутності ключа —
AuthenticateResult.NoResult() - При невалідному —
AuthenticateResult.Fail("Invalid API key") - Тест: запит без ключа → 401, запит з валідним ключем → 200
Рівень 2: Проєктування
Реалізуйте повну систему scopes:
- Визначте 4 scopes:
read,write,admin,billing - Додайте policies для кожного scope
- Ендпоінти:
GET /api/data(read),POST /api/data(write),DELETE /api/data(admin) - Створіть ключ лише з
readscope — перевірте, щоPOSTповертає 403 - Реалізуйте
ScopeAuthorizationHandlerз OR-логікою:read OR admin
Реалізуйте ротацію ключів:
POST /api/keys/{id}/rotate— новий ключ зі старими scopes- Grace period: старий ключ залишається дійсним 24 години
- Endpoint:
GET /api/keys/{id}/rotation-status— скільки часу до закінчення старого - Протестуйте: обидва ключа (старий і новий) дійсні в grace period
- Після grace period — старий ключ повертає 401
Рівень 3: Архітектура
Побудуйте повноцінну API Key систему:
- Prefix-based routing:
sk_live_→ production middleware,sk_test_→ test environment - IP whitelist per key:
AllowedIPsв моделі, перевірка у handler - Usage tracking в Redis (лічильник запитів, batch update кожні 60 сек у БД)
- Audit log таблиця: кожен запит з API Key фіксується з timestamp, IP, endpoint, status_code
- Admin API:
GET /admin/api-keys— всі ключі, статистика використання, можливість відкликання
13. Резюме
Хешуй, не зберігай
AuthenticationHandler
AuthenticationHandler<TOptions> — стандартний спосіб додати кастомну схему. Читає X-Api-Key, валідує, будує ClaimsPrincipal.Scopes = fine-grained permissions
products:read, orders:write, admin. Claims у Principal, Policy через RequireClaim("scope", value).Ротація без простою
Далі: у наступній статті — Rate Limiting та Throttling у ASP.NET Core: вбудований RateLimiter, sliding window, token bucket, per-user та per-IP стратегії.
OIDC, OAuth 2.0 та Keycloak в ASP.NET Core
Глибоке занурення в OpenID Connect та OAuth 2.0: навіщо вони потрібні, як працює Authorization Code Flow з PKCE, інтеграція з Keycloak як Identity Provider, JWT-валідація для API та Machine-to-Machine комунікація.
Rate Limiting та Throttling в ASP.NET Core
Глибоке занурення в Rate Limiting: навіщо він потрібен, як працюють чотири алгоритми, per-user та per-IP стратегії, Redis для розподілених систем та правильне проєктування відміни запитів.