Backend (17 files): - VAT: REP 25% deductibility (§42), EU reverse charge double-entry (IEUV/IEUY/IVY), IVY rate 0%→25%, VatReport Box C/D populated, Basis1 from real revenue - SAF-T: correct OECD namespace, closing balance net calc, zero-amount fallback, credit note auto-numbering (§52) - Security: BankingController CSRF state token + company auth check, attachment canonical path traversal check, discount 0-100% validation, deactivated product/customer update guard - Quality: redact bank API logs, remove dead code (VatCalcService, PaymentMatchingService), CompanyAggregate IEmit interfaces, fix URL encoding Frontend (15 files): - Fix double "kr." in AmountText and Dashboard Statistic components - Fix UserSettings Switch defaultChecked desync with Form state - Remove dual useCompany/useCompanyStore pattern (Dashboard, Moms, Bank) - Correct SKAT VAT deadline calculation per period type - Add half-yearly/yearly VAT period options - Guard console.error with import.meta.env.DEV - Use shared formatDate in BankConnectionsTab - Remove dead NONE vatCode check, purge 7 legacy VAT codes from type union - Migrate S25→U25, K25→I25 across all pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
7.2 KiB
C#
188 lines
7.2 KiB
C#
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Books.Api.Authorization;
|
|
using Books.Api.Banking;
|
|
using Books.Api.Commands.BankConnections;
|
|
using Books.Api.Domain.BankConnections;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using EventFlow;
|
|
using EventFlow.Aggregates.ExecutionResults;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace Books.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/banking")]
|
|
[Authorize]
|
|
public class BankingController : ControllerBase
|
|
{
|
|
private readonly ICommandBus _commandBus;
|
|
private readonly IEnableBankingClient _bankingClient;
|
|
private readonly ILogger<BankingController> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly IBankConnectionRepository _bankConnectionRepository;
|
|
private readonly ICompanyAccessService _companyAccess;
|
|
|
|
public BankingController(
|
|
ICommandBus commandBus,
|
|
IEnableBankingClient bankingClient,
|
|
ILogger<BankingController> logger,
|
|
IConfiguration configuration,
|
|
IBankConnectionRepository bankConnectionRepository,
|
|
ICompanyAccessService companyAccess)
|
|
{
|
|
_commandBus = commandBus;
|
|
_bankingClient = bankingClient;
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
_bankConnectionRepository = bankConnectionRepository;
|
|
_companyAccess = companyAccess;
|
|
}
|
|
|
|
/// <summary>
|
|
/// OAuth callback from Enable Banking after user authorizes bank connection.
|
|
/// </summary>
|
|
[HttpGet("callback")]
|
|
public async Task<IActionResult> Callback(
|
|
[FromQuery] string? code,
|
|
[FromQuery] string? state,
|
|
[FromQuery] string? error,
|
|
[FromQuery] string? error_description,
|
|
CancellationToken ct)
|
|
{
|
|
var frontendBaseUrl = _configuration["Frontend:BaseUrl"] ?? "http://localhost:3000";
|
|
var redirectUrl = $"{frontendBaseUrl}/indstillinger?tab=bankAccounts";
|
|
|
|
// Handle error from bank
|
|
if (!string.IsNullOrEmpty(error))
|
|
{
|
|
_logger.LogWarning("Bank authorization failed: {Error} - {Description}", error, error_description);
|
|
return Redirect($"{redirectUrl}&error={Uri.EscapeDataString(error_description ?? error)}");
|
|
}
|
|
|
|
// Validate required parameters
|
|
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
|
{
|
|
_logger.LogWarning("Missing code or state in callback");
|
|
return Redirect($"{redirectUrl}&error=missing_parameters");
|
|
}
|
|
|
|
try
|
|
{
|
|
// Validate HMAC-signed state token to prevent CSRF attacks
|
|
var connectionId = ValidateStateToken(state);
|
|
if (connectionId == null)
|
|
{
|
|
_logger.LogWarning("Invalid or tampered state token in bank callback");
|
|
return Redirect($"{redirectUrl}&error=invalid_state");
|
|
}
|
|
|
|
// Verify the user has access to the company that owns this bank connection
|
|
var bankConnection = await _bankConnectionRepository.GetByIdAsync(connectionId, ct);
|
|
if (bankConnection == null)
|
|
{
|
|
_logger.LogWarning("Bank connection {ConnectionId} not found", connectionId);
|
|
return Redirect($"{redirectUrl}&error=connection_not_found");
|
|
}
|
|
|
|
var canWrite = await _companyAccess.CanWriteAsync(bankConnection.CompanyId, ct);
|
|
if (!canWrite)
|
|
{
|
|
_logger.LogWarning(
|
|
"User does not have write access to company {CompanyId} for bank connection {ConnectionId}",
|
|
bankConnection.CompanyId, connectionId);
|
|
return Redirect($"{redirectUrl}&error=access_denied");
|
|
}
|
|
|
|
// Get PSU headers from HttpContext (required by Enable Banking API)
|
|
var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
var psuUserAgent = Request.Headers.UserAgent.ToString();
|
|
|
|
// Exchange authorization code for session
|
|
var session = await _bankingClient.CreateSessionAsync(code, psuIpAddress, psuUserAgent, ct);
|
|
|
|
_logger.LogInformation(
|
|
"Bank session created: {SessionId}, Bank: {Bank}, Accounts: {AccountCount}",
|
|
session.SessionId,
|
|
session.AspspName,
|
|
session.Accounts.Count);
|
|
|
|
// Complete the bank connection
|
|
var command = new EstablishBankConnectionCommand(
|
|
BankConnectionId.With(connectionId),
|
|
session.SessionId,
|
|
session.ValidUntil,
|
|
session.Accounts.Select(a => new BankAccountInfo(
|
|
a.AccountId,
|
|
a.Iban,
|
|
a.Currency,
|
|
a.AccountName)).ToList());
|
|
|
|
var result = await _commandBus.PublishAsync(command, ct);
|
|
|
|
if (result is FailedExecutionResult failed)
|
|
{
|
|
_logger.LogError("Failed to complete bank connection: {Errors}", string.Join(", ", failed.Errors));
|
|
return Redirect($"{redirectUrl}&error=completion_failed");
|
|
}
|
|
|
|
_logger.LogInformation("Bank connection {ConnectionId} completed successfully", connectionId);
|
|
return Redirect($"{redirectUrl}&success=true");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error completing bank connection");
|
|
return Redirect($"{redirectUrl}&error=internal_error");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates an HMAC-signed state token containing the connection ID.
|
|
/// Used during StartBankConnection to create a CSRF-safe state parameter.
|
|
/// </summary>
|
|
public static string GenerateStateToken(string connectionId, string secret)
|
|
{
|
|
var signature = ComputeHmac(connectionId, secret);
|
|
return $"{connectionId}.{signature}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an HMAC-signed state token and extracts the connection ID.
|
|
/// Returns null if the token is invalid or tampered with.
|
|
/// </summary>
|
|
private string? ValidateStateToken(string state)
|
|
{
|
|
var secret = _configuration["Banking:StateSecret"] ?? _configuration["Jwt:Key"] ?? "";
|
|
|
|
// Support legacy format (plain connection ID without signature) during transition
|
|
if (!state.Contains('.'))
|
|
{
|
|
// Legacy format: treat as plain connection ID but log a warning
|
|
_logger.LogWarning("Bank callback received legacy state format without HMAC signature");
|
|
return state;
|
|
}
|
|
|
|
var dotIndex = state.LastIndexOf('.');
|
|
var connectionId = state[..dotIndex];
|
|
var providedSignature = state[(dotIndex + 1)..];
|
|
|
|
var expectedSignature = ComputeHmac(connectionId, secret);
|
|
if (!CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(expectedSignature),
|
|
Encoding.UTF8.GetBytes(providedSignature)))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return connectionId;
|
|
}
|
|
|
|
private static string ComputeHmac(string data, string secret)
|
|
{
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
|
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
|
|
return Convert.ToBase64String(hash);
|
|
}
|
|
}
|