diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 81fb219..d25ee36 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -187,14 +187,14 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Fetch → pipeline → store → publish. Same response shape as 5.1. - Map `ImageFetchException` to `502 Bad Gateway`. -### [ ] 7.3 Manual check +### [x] 7.3 Manual check - `curl -H "Content-Type: application/json" -d '{"url":"https://..."}' .../image-url` works end-to-end. --- ## Phase 8 — Auth + concurrency + robustness -### [ ] 8.1 `ApiKeyMiddleware` +### [x] 8.1 `ApiKeyMiddleware` - Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions` only if `ApiKeyOptions`is set to non-empty. - 401 on mismatch. `/i/{mac}.png` and `/health` unaffected. diff --git a/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs b/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..49377a7 --- /dev/null +++ b/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,48 @@ +using FrameProcessor.Configuration; +using FrameProcessor.Domain; +using Microsoft.Extensions.Options; + +namespace FrameProcessor.Middleware; + +/// +/// Enforces an X-Api-Key header on /api/* requests. Other paths +/// (notably /i/{mac}.png and /health) pass through untouched. +/// When the configured key is empty the middleware is a no-op. +/// +public sealed class ApiKeyMiddleware +{ + private const string HeaderName = "X-Api-Key"; + private readonly RequestDelegate _next; + private readonly IOptionsMonitor _options; + + public ApiKeyMiddleware(RequestDelegate next, IOptionsMonitor options) + { + _next = next; + _options = options; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)) + { + await _next(context).ConfigureAwait(false); + return; + } + + var configured = _options.CurrentValue.Value; + if (string.IsNullOrEmpty(configured)) + { + await _next(context).ConfigureAwait(false); + return; + } + + var provided = context.Request.Headers[HeaderName].ToString(); + if (!new ApiKey(configured).Matches(provided)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await _next(context).ConfigureAwait(false); + } +} diff --git a/src/FrameProcessor/Program.cs b/src/FrameProcessor/Program.cs index 7afac52..4486b17 100644 --- a/src/FrameProcessor/Program.cs +++ b/src/FrameProcessor/Program.cs @@ -1,5 +1,6 @@ using FrameProcessor.Configuration; using FrameProcessor.ImagePipeline; +using FrameProcessor.Middleware; using FrameProcessor.Mqtt; using FrameProcessor.Storage; using FrameProcessor.UrlFetch; @@ -64,6 +65,8 @@ var app = builder.Build(); // Eagerly resolve FramesRegistry so an invalid frames.json fails startup fast. _ = app.Services.GetRequiredService(); +app.UseMiddleware(); + app.MapControllers(); app.Run();