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>
114 lines
3.8 KiB
C#
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);
|
|
}
|