QrRapido/Filters/ApiKeyAuthorizeAttribute.cs
Ricardo Carneiro 7a0c12f8d2
Some checks failed
Deploy QR Rapido / test (push) Failing after 17s
Deploy QR Rapido / build-and-push (push) Has been skipped
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Has been skipped
feat: api separada do front-end e area do desenvolvedor.
2026-03-08 12:40:51 -03:00

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);
}
}
}
}
}