books/backend/Books.Api/Authentication/ApiKeyAuthenticationHandler.cs
Nicolaj Hartmann 8e05171b66 Full product audit: fix security, compliance, UX, and wire broken features
Security (Phase 1):
- Add authentication middleware on /graphql endpoint
- Filter company queries by user access (prevent IDOR)
- Add role-based authorization on mutations (owner/accountant)
- Reduce API key cache TTL from 24h to 5 minutes
- Hide exception details in production GraphQL errors
- Fix RBAC in frontend companyStore (was hardcoded)

Wiring broken features (Phase 2):
- Wire Kassekladde submit/void/copy to GraphQL mutations
- Wire Kontooversigt account creation to createAccount mutation
- Wire Settings save to updateCompany mutation
- Wire CreateFiscalYearModal and CloseFiscalYearWizard to mutations
- Replace Momsindberetning mock data with real useVatReport query
- Remove Dashboard hardcoded percentages and fake VAT deadline
- Fix Kreditnotaer invoice selector to use real data
- Fix mutation retry from 1 to 0 (prevent duplicate operations)

Accounting compliance (Phase 3):
- Add balanced entry validation (debit==credit) in JournalEntryDraftAggregate
- Add fiscal year boundary enforcement (status, date range checks)
- Add PostedAt timestamp to posted events (Bogføringsloven §7)
- Add account number uniqueness check within company
- Add fiscal year overlap and gap checks
- Add sequential invoice auto-numbering
- Fix InvoiceLine VAT rate to use canonical VatCodes
- Fix SAF-T account type mapping (financial → Expense)
- Add DraftLine validation (cannot have both debit and credit > 0)

UX improvements (Phase 4):
- Fix Danish character encoding across 15+ files (ø, æ, å)
- Deploy DemoDataDisclaimer on pages with mock/incomplete data
- Adopt PageHeader component universally across all pages
- Standardize active/inactive filtering to Switch pattern
- Fix dead buttons in Header (Help, Notifications)
- Remove hardcoded mock data from Settings
- Fix Sidebar controlled state and Kontooversigt navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:35:26 +01:00

114 lines
3.8 KiB
C#

using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Books.Api.EventFlow.Repositories;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace Books.Api.Authentication;
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public string HeaderName { get; set; } = ApiKeyDefaults.HeaderName;
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(5);
}
public class ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IMemoryCache cache,
IServiceProvider serviceProvider)
: AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(Options.HeaderName, out var apiKeyHeader))
{
return AuthenticateResult.NoResult();
}
var apiKeyValue = apiKeyHeader.ToString();
if (string.IsNullOrWhiteSpace(apiKeyValue))
{
return AuthenticateResult.NoResult();
}
// Expected format: {keyId}.{secret}
var parts = apiKeyValue.Split('.', 2);
if (parts.Length != 2)
{
return AuthenticateResult.Fail("Invalid API key format. Expected: {keyId}.{secret}");
}
var keyId = parts[0];
var secret = parts[1];
// Check cache first
var cacheKey = $"apikey:{keyId}";
if (cache.TryGetValue(cacheKey, out CachedApiKeyInfo? cachedInfo) && cachedInfo != null)
{
var providedHash = HashSecret(secret);
if (cachedInfo.KeyHash == providedHash)
{
return AuthenticateResult.Success(
new AuthenticationTicket(cachedInfo.Principal, Scheme.Name));
}
return AuthenticateResult.Fail("Invalid API key");
}
// Cache miss - lookup in database
using var scope = serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetService<IApiKeyRepository>();
if (repository == null)
{
Logger.LogWarning("IApiKeyRepository not registered - API key authentication unavailable");
return AuthenticateResult.NoResult();
}
var apiKey = await repository.GetByIdForValidationAsync(keyId);
if (apiKey == null)
{
return AuthenticateResult.Fail("Invalid API key");
}
// Verify hash
var secretHash = HashSecret(secret);
if (apiKey.KeyHash != secretHash)
{
return AuthenticateResult.Fail("Invalid API key");
}
// Build claims
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, apiKey.ApiKeyId),
new(ClaimTypes.Name, apiKey.Name),
new("company_id", apiKey.CompanyId),
new("preferred_username", $"apikey:{apiKey.Name}"),
new(ClaimTypes.Role, "api_client"),
new(ClaimTypes.AuthenticationMethod, "api_key")
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
// Cache the result
var cacheInfo = new CachedApiKeyInfo(secretHash, principal);
cache.Set(cacheKey, cacheInfo, Options.CacheDuration);
return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
private static string HashSecret(string secret)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(secret));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private sealed record CachedApiKeyInfo(string KeyHash, ClaimsPrincipal Principal);
}