163 lines
7.2 KiB
C#
163 lines
7.2 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Filters;
|
|
using QRRapidoApp.Models;
|
|
using QRRapidoApp.Services;
|
|
|
|
namespace QRRapidoApp.Filters
|
|
{
|
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
|
public class ApiKeyAuthorizeAttribute : Attribute, IAsyncActionFilter
|
|
{
|
|
private const string ApiKeyHeaderName = "X-API-Key";
|
|
|
|
// Tracks 429 events per key for abuse logging (key: prefix, value: list of timestamps)
|
|
// In-process only; acceptable for the abuse detection use case.
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, System.Collections.Generic.Queue<DateTime>> _abuseTracker = new();
|
|
|
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|
{
|
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ApiKeyAuthorizeAttribute>>();
|
|
|
|
try
|
|
{
|
|
if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey))
|
|
{
|
|
logger.LogWarning("API Key missing in request headers from {IP}", GetIp(context));
|
|
context.Result = JsonError(401, "API Key not provided. Use the X-API-Key header.");
|
|
return;
|
|
}
|
|
|
|
var userService = context.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
|
var user = await userService.GetUserByApiKeyAsync(extractedApiKey!);
|
|
|
|
if (user == null)
|
|
{
|
|
var masked = MaskKey(extractedApiKey.ToString());
|
|
logger.LogWarning("Invalid API Key {Masked} from {IP}", masked, GetIp(context));
|
|
context.Result = JsonError(401, "Unauthorized. Invalid or revoked API Key.");
|
|
return;
|
|
}
|
|
|
|
// Find the matching ApiKeyConfig by prefix (first 8 chars of raw key)
|
|
var rawKey = extractedApiKey.ToString();
|
|
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
|
var apiKeyConfig = user.ApiKeys.FirstOrDefault(k => k.Prefix == prefix && k.IsActive);
|
|
|
|
if (apiKeyConfig == null)
|
|
{
|
|
context.Result = JsonError(403, "API Key is revoked or not found.");
|
|
return;
|
|
}
|
|
|
|
// Plan tier comes from the user's active API subscription, not from the key config.
|
|
// This ensures that upgrading/downgrading immediately affects all keys.
|
|
var effectiveTier = user.ApiSubscription?.EffectiveTier ?? ApiPlanTier.Free;
|
|
|
|
// Rate limit check
|
|
var rateLimitService = context.HttpContext.RequestServices.GetRequiredService<IApiRateLimitService>();
|
|
var rl = await rateLimitService.CheckAndIncrementAsync(prefix, effectiveTier);
|
|
|
|
// Always inject rate limit headers
|
|
AddRateLimitHeaders(context.HttpContext, rl);
|
|
|
|
if (!rl.Allowed)
|
|
{
|
|
var ip = GetIp(context);
|
|
TrackAbuse(prefix, logger, ip);
|
|
|
|
var message = rl.MonthlyExceeded
|
|
? $"Monthly quota exceeded ({rl.MonthlyLimit} requests). Upgrade your plan at qrrapido.site/Developer."
|
|
: $"Rate limit exceeded ({rl.PerMinuteLimit} req/min). Retry after {rl.ResetUnixTimestamp - DateTimeOffset.UtcNow.ToUnixTimeSeconds()}s.";
|
|
|
|
context.HttpContext.Response.Headers["Retry-After"] =
|
|
(rl.ResetUnixTimestamp - DateTimeOffset.UtcNow.ToUnixTimeSeconds()).ToString();
|
|
|
|
context.Result = JsonError(429, message, new
|
|
{
|
|
plan = ApiPlanLimits.PlanName(apiKeyConfig.PlanTier),
|
|
upgradeUrl = "https://qrrapido.site/Developer"
|
|
});
|
|
return;
|
|
}
|
|
|
|
context.HttpContext.Items["ApiKeyUserId"] = user.Id;
|
|
context.HttpContext.Items["ApiKeyPrefix"] = prefix;
|
|
context.HttpContext.Items["ApiPlanTier"] = effectiveTier;
|
|
|
|
await next();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error during API Key authorization.");
|
|
context.Result = JsonError(500, "Internal server error during authorization.");
|
|
}
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
|
|
private static void AddRateLimitHeaders(HttpContext ctx, RateLimitResult rl)
|
|
{
|
|
if (rl.PerMinuteLimit >= 0)
|
|
{
|
|
ctx.Response.Headers["X-RateLimit-Limit"] = rl.PerMinuteLimit.ToString();
|
|
ctx.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, rl.PerMinuteLimit - rl.PerMinuteUsed).ToString();
|
|
}
|
|
else
|
|
{
|
|
ctx.Response.Headers["X-RateLimit-Limit"] = "unlimited";
|
|
ctx.Response.Headers["X-RateLimit-Remaining"] = "unlimited";
|
|
}
|
|
|
|
ctx.Response.Headers["X-RateLimit-Reset"] = rl.ResetUnixTimestamp.ToString();
|
|
|
|
if (rl.MonthlyLimit >= 0)
|
|
{
|
|
ctx.Response.Headers["X-Quota-Limit"] = rl.MonthlyLimit.ToString();
|
|
ctx.Response.Headers["X-Quota-Remaining"] = Math.Max(0, rl.MonthlyLimit - rl.MonthlyUsed).ToString();
|
|
}
|
|
else
|
|
{
|
|
ctx.Response.Headers["X-Quota-Limit"] = "unlimited";
|
|
ctx.Response.Headers["X-Quota-Remaining"] = "unlimited";
|
|
}
|
|
}
|
|
|
|
private static ObjectResult JsonError(int status, string message, object? extra = null)
|
|
{
|
|
object body = extra == null
|
|
? new { error = message }
|
|
: new { error = message, details = extra };
|
|
|
|
return new ObjectResult(body) { StatusCode = status };
|
|
}
|
|
|
|
private static string GetIp(ActionExecutingContext ctx) =>
|
|
ctx.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
|
|
|
private static string MaskKey(string key) =>
|
|
key.Length > 8 ? key[..8] + "..." : "***";
|
|
|
|
/// <summary>Logs a Warning when the same key hits 429 three times within 60 seconds.</summary>
|
|
private static void TrackAbuse(string prefix, ILogger logger, string ip)
|
|
{
|
|
var queue = _abuseTracker.GetOrAdd(prefix, _ => new System.Collections.Generic.Queue<DateTime>());
|
|
|
|
lock (queue)
|
|
{
|
|
var cutoff = DateTime.UtcNow.AddSeconds(-60);
|
|
while (queue.Count > 0 && queue.Peek() < cutoff)
|
|
queue.Dequeue();
|
|
|
|
queue.Enqueue(DateTime.UtcNow);
|
|
|
|
if (queue.Count >= 3)
|
|
{
|
|
logger.LogWarning(
|
|
"Potential abuse: API key prefix {Prefix} received {Count} rate-limit rejections in the last 60s. Client IP: {IP}",
|
|
prefix, queue.Count, ip);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|