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> _abuseTracker = new(); public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var logger = context.HttpContext.RequestServices.GetRequiredService>(); 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(); 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(); 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] + "..." : "***"; /// Logs a Warning when the same key hits 429 three times within 60 seconds. private static void TrackAbuse(string prefix, ILogger logger, string ip) { var queue = _abuseTracker.GetOrAdd(prefix, _ => new System.Collections.Generic.Queue()); 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); } } } } }