diff --git a/EnvelopeGenerator.API/Controllers/ReceiverAuthController.cs b/EnvelopeGenerator.API/Controllers/ReceiverAuthController.cs new file mode 100644 index 00000000..19a87389 --- /dev/null +++ b/EnvelopeGenerator.API/Controllers/ReceiverAuthController.cs @@ -0,0 +1,428 @@ +using EnvelopeGenerator.API.Models; +using EnvelopeGenerator.Application.Common.Extensions; +using EnvelopeGenerator.Application.Common.Interfaces.Services; +using EnvelopeGenerator.Application.EnvelopeReceivers.Queries; +using EnvelopeGenerator.Domain.Constants; +using EnvelopeGenerator.API.Extensions; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using OtpNet; + +namespace EnvelopeGenerator.API.Controllers; + +/// +/// REST-API für den Empfänger-Authentifizierungs-Flow. +/// +/// Entspricht der Logik in EnvelopeGenerator.Web.Controllers.EnvelopeController +/// (Main + LogInEnvelope), aber gibt JSON statt Views zurück. +/// +/// Der Blazor-Client (ReceiverUI) ruft diese Endpunkte auf. +/// +/// FLOW: +/// 1. Client ruft GET /api/receiverauth/{key}/status → Prüft Status +/// 2. Client ruft POST /api/receiverauth/{key}/access-code → Sendet AccessCode +/// 3. Client ruft POST /api/receiverauth/{key}/tfa → Sendet TFA-Code +/// +/// Nach erfolgreicher Authentifizierung wird ein Cookie gesetzt (SignInEnvelopeAsync). +/// Danach kann der Client die Dokument-Daten über die bestehenden Envelope-Endpunkte laden. +/// +[Route("api/[controller]")] +[ApiController] +public class ReceiverAuthController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly IEnvelopeReceiverService _envRcvService; + private readonly IEnvelopeHistoryService _historyService; + private readonly IEnvelopeMailService _mailService; + private readonly IAuthenticator _authenticator; + private readonly IReceiverService _rcvService; + private readonly IEnvelopeSmsHandler _envSmsHandler; + + public ReceiverAuthController( + ILogger logger, + IMediator mediator, + IEnvelopeReceiverService envRcvService, + IEnvelopeHistoryService historyService, + IEnvelopeMailService mailService, + IAuthenticator authenticator, + IReceiverService rcvService, + IEnvelopeSmsHandler envSmsHandler) + { + _logger = logger; + _mediator = mediator; + _envRcvService = envRcvService; + _historyService = historyService; + _mailService = mailService; + _authenticator = authenticator; + _rcvService = rcvService; + _envSmsHandler = envSmsHandler; + } + + // ══════════════════════════════════════════════════════════════ + // ENDPUNKT 1: STATUS PRÜFEN + // Entspricht: Web.EnvelopeController.Main() + // ══════════════════════════════════════════════════════════════ + + /// + /// Prüft den aktuellen Status eines Umschlags für den Empfänger. + /// Entscheidet ob: NotFound, Rejected, Signed, AccessCode nötig, oder direkt anzeigen. + /// + /// Der EnvelopeReceiver-Key aus der URL (Base64-kodiert) + /// Cancellation-Token + /// ReceiverAuthResponse mit dem aktuellen Status + [HttpGet("{key}/status")] + public async Task GetStatus([FromRoute] string key, CancellationToken cancel) + { + try + { + // ── Key dekodieren ── + // Entspricht: if (!envelopeReceiverId.TryDecode(out var decoded)) + if (!key.TryDecode(out var decoded)) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + // ── ReadOnly-Links ── + // Entspricht: if (decoded.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly) + if (decoded.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly) + { + // ReadOnly-Logik: Prüfe ob abgelaufen + // Wir geben erstmal show_document zurück, ReadOnly-Details kommen in Phase 6 + return Ok(new ReceiverAuthResponse + { + Status = "show_document", + ReadOnly = true + }); + } + + // ── EnvelopeReceiver laden ── + // Entspricht: var er = await _mediator.ReadEnvelopeReceiverAsync(envelopeReceiverId, cancel); + var er = await _mediator.ReadEnvelopeReceiverAsync(key, cancel); + if (er is null) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + // ── Abgelehnt? ── + // Entspricht: var rejRcvrs = await _historyService.ReadRejectingReceivers(er.Envelope!.Id); + var rejRcvrs = await _historyService.ReadRejectingReceivers(er.Envelope!.Id); + if (rejRcvrs.Any()) + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(new ReceiverAuthResponse + { + Status = "rejected", + Title = er.Envelope.Title, + SenderEmail = er.Envelope.User?.Email + }); + } + + // ── Bereits signiert? ── + // Entspricht: if (await _historyService.IsSigned(...)) + if (await _historyService.IsSigned( + envelopeId: er.Envelope.Id, + userReference: er.Receiver!.EmailAddress)) + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(new ReceiverAuthResponse + { + Status = "already_signed", + Title = er.Envelope.Title, + SenderEmail = er.Envelope.User?.Email + }); + } + + // ── Kein AccessCode nötig? → Direkt SignIn ── + // Entspricht: if (!er.Envelope!.UseAccessCode) + if (!er.Envelope.UseAccessCode) + { + (string? uuid, string? signature) = decoded.ParseEnvelopeReceiverId(); + var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync( + uuid: uuid!, signature: signature!); + + if (erSecretRes.IsFailed) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + await HttpContext.SignInEnvelopeAsync(erSecretRes.Data, Role.ReceiverFull); + + return Ok(new ReceiverAuthResponse + { + Status = "show_document", + Title = er.Envelope.Title, + Message = er.Envelope.Message, + SenderEmail = er.Envelope.User?.Email, + ReadOnly = er.Envelope.ReadOnly + }); + } + + // ── AccessCode nötig → Code senden (wenn noch nicht gesendet) ── + // Entspricht: bool accessCodeAlreadyRequested = ... + bool accessCodeAlreadyRequested = await _historyService.AccessCodeAlreadyRequested( + envelopeId: er.Envelope.Id, + userReference: er.Receiver.EmailAddress); + + if (!accessCodeAlreadyRequested) + { + await _historyService.RecordAsync( + er.EnvelopeId, er.Receiver.EmailAddress, EnvelopeStatus.AccessCodeRequested); + + await _mailService.SendAccessCodeAsync(envelopeReceiverDto: er); + } + + // ── Prüfe ob der Nutzer bereits eingeloggt ist ── + // Entspricht: CreateEnvelopeLockedView → Prüfung ob User.IsInRole(ReceiverFull) + if (User.IsInRole(Role.ReceiverFull)) + { + return Ok(new ReceiverAuthResponse + { + Status = "show_document", + Title = er.Envelope.Title, + Message = er.Envelope.Message, + SenderEmail = er.Envelope.User?.Email, + ReadOnly = er.Envelope.ReadOnly + }); + } + + return Ok(new ReceiverAuthResponse + { + Status = "requires_access_code", + Title = er.Envelope.Title, + SenderEmail = er.Envelope.User?.Email, + TfaEnabled = er.Envelope.TFAEnabled, + HasPhoneNumber = er.HasPhoneNumber + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking status for key {Key}", key); + return StatusCode(500, new ReceiverAuthResponse + { + Status = "error", + ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten." + }); + } + } + + // ══════════════════════════════════════════════════════════════ + // ENDPUNKT 2: ACCESS-CODE PRÜFEN + // Entspricht: Web.EnvelopeController.LogInEnvelope() → HandleAccessCodeAsync() + // ══════════════════════════════════════════════════════════════ + + /// + /// Prüft den eingegebenen Zugangscode. + /// Bei Erfolg: SignIn oder TFA-Weiterleitung. + /// Bei Fehler: Fehlermeldung zurückgeben. + /// + [HttpPost("{key}/access-code")] + public async Task SubmitAccessCode( + [FromRoute] string key, + [FromBody] AccessCodeRequest request, + CancellationToken cancel) + { + try + { + // ── Key dekodieren + Daten laden ── + (string? uuid, string? signature) = key.DecodeEnvelopeReceiverId(); + if (uuid is null || signature is null) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync( + uuid: uuid, signature: signature); + + if (erSecretRes.IsFailed) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + var erSecret = erSecretRes.Data; + + // ── AccessCode prüfen ── + // Entspricht: HandleAccessCodeAsync() → if (er_secret.AccessCode != auth.AccessCode) + if (erSecret.AccessCode != request.AccessCode) + { + await _historyService.RecordAsync( + erSecret.EnvelopeId, + erSecret.Receiver!.EmailAddress, + EnvelopeStatus.AccessCodeIncorrect); + + return Unauthorized(new ReceiverAuthResponse + { + Status = "requires_access_code", + Title = erSecret.Envelope!.Title, + SenderEmail = erSecret.Envelope.User?.Email, + TfaEnabled = erSecret.Envelope.TFAEnabled, + HasPhoneNumber = erSecret.HasPhoneNumber, + ErrorMessage = "Falscher Zugangscode." + }); + } + + // ── AccessCode korrekt ── + await _historyService.RecordAsync( + erSecret.EnvelopeId, + erSecret.Receiver!.EmailAddress, + EnvelopeStatus.AccessCodeCorrect); + + // ── TFA erforderlich? ── + // Entspricht: if (er_secret.Envelope!.TFAEnabled) + if (erSecret.Envelope!.TFAEnabled) + { + // TotpSecretKey generieren falls noch nicht vorhanden + var rcv = erSecret.Receiver; + if (rcv.TotpSecretkey is null) + { + rcv.TotpSecretkey = _authenticator.GenerateTotpSecretKey(); + await _rcvService.UpdateAsync(rcv); + } + + // SignIn mit TFA-Rolle (eingeschränkt — nur TFA erlaubt, kein Dokument) + await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverTFA); + + // SMS senden wenn vom Benutzer gewählt + if (request.PreferSms) + { + var (smsRes, expiration) = await _envSmsHandler.SendTotpAsync(erSecret); + + return Ok(new ReceiverAuthResponse + { + Status = "requires_tfa", + TfaType = "sms", + TfaExpiration = expiration, + Title = erSecret.Envelope.Title, + SenderEmail = erSecret.Envelope.User?.Email, + HasPhoneNumber = erSecret.HasPhoneNumber + }); + } + else + { + return Ok(new ReceiverAuthResponse + { + Status = "requires_tfa", + TfaType = "authenticator", + Title = erSecret.Envelope.Title, + SenderEmail = erSecret.Envelope.User?.Email, + HasPhoneNumber = erSecret.HasPhoneNumber + }); + } + } + + // ── Kein TFA → Direkt SignIn ── + await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverFull); + + return Ok(new ReceiverAuthResponse + { + Status = "show_document", + Title = erSecret.Envelope.Title, + Message = erSecret.Envelope.Message, + SenderEmail = erSecret.Envelope.User?.Email, + ReadOnly = erSecret.Envelope.ReadOnly + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting access code for key {Key}", key); + return StatusCode(500, new ReceiverAuthResponse + { + Status = "error", + ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten." + }); + } + } + + // ══════════════════════════════════════════════════════════════ + // ENDPUNKT 3: TFA-CODE PRÜFEN + // Entspricht: Web.EnvelopeController.LogInEnvelope() → HandleSmsAsync/HandleAuthenticatorAsync + // ══════════════════════════════════════════════════════════════ + + /// + /// Prüft den TFA-Code (SMS oder Authenticator). + /// Setzt voraus, dass der Nutzer bereits mit ReceiverTFA-Rolle eingeloggt ist. + /// + [HttpPost("{key}/tfa")] + public async Task SubmitTfaCode( + [FromRoute] string key, + [FromBody] TfaCodeRequest request, + CancellationToken cancel) + { + try + { + // ── Prüfe ob der Nutzer TFA-berechtigt ist ── + if (!User.IsInRole(Role.ReceiverTFA)) + return Unauthorized(new ReceiverAuthResponse + { + Status = "requires_access_code", + ErrorMessage = "Bitte zuerst den Zugangscode eingeben." + }); + + // ── Daten laden ── + (string? uuid, string? signature) = key.DecodeEnvelopeReceiverId(); + if (uuid is null || signature is null) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync( + uuid: uuid, signature: signature); + + if (erSecretRes.IsFailed) + return NotFound(new ReceiverAuthResponse { Status = "not_found" }); + + var erSecret = erSecretRes.Data; + + if (erSecret.Receiver!.TotpSecretkey is null) + { + _logger.LogError("TotpSecretkey is null for receiver {Signature}", signature); + return StatusCode(500, new ReceiverAuthResponse + { + Status = "error", + ErrorMessage = "TFA-Konfiguration fehlt." + }); + } + + // ── Code verifizieren ── + bool codeValid; + + if (request.Type == "sms") + { + // Entspricht: HandleSmsAsync() + codeValid = _envSmsHandler.VerifyTotp(request.Code, erSecret.Receiver.TotpSecretkey); + } + else + { + // Entspricht: HandleAuthenticatorAsync() + codeValid = _authenticator.VerifyTotp( + request.Code, + erSecret.Receiver.TotpSecretkey, + window: VerificationWindow.RfcSpecifiedNetworkDelay); + } + + if (!codeValid) + { + return Unauthorized(new ReceiverAuthResponse + { + Status = "requires_tfa", + TfaType = request.Type, + Title = erSecret.Envelope!.Title, + SenderEmail = erSecret.Envelope.User?.Email, + HasPhoneNumber = erSecret.HasPhoneNumber, + ErrorMessage = "Falscher Code." + }); + } + + // ── TFA erfolgreich → Voll-SignIn ── + await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverFull); + + return Ok(new ReceiverAuthResponse + { + Status = "show_document", + Title = erSecret.Envelope!.Title, + Message = erSecret.Envelope.Message, + SenderEmail = erSecret.Envelope.User?.Email, + ReadOnly = erSecret.Envelope.ReadOnly + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error submitting TFA code for key {Key}", key); + return StatusCode(500, new ReceiverAuthResponse + { + Status = "error", + ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten." + }); + } + } +} \ No newline at end of file diff --git a/EnvelopeGenerator.API/Models/ReceiverAuthResponse.cs b/EnvelopeGenerator.API/Models/ReceiverAuthResponse.cs new file mode 100644 index 00000000..390e673d --- /dev/null +++ b/EnvelopeGenerator.API/Models/ReceiverAuthResponse.cs @@ -0,0 +1,78 @@ +namespace EnvelopeGenerator.API.Models; + +/// +/// Einheitliche Antwort des ReceiverAuthControllers. +/// +/// WARUM ein einziges Response-Objekt für alle Endpunkte? +/// - Der Client braucht nur ein Format zu verstehen +/// - Der Status-String bestimmt, welche Felder relevant sind +/// - Entspricht dem, was der Web-Controller bisher über ViewData verteilt hat +/// +/// Status-Werte und was sie bedeuten: +/// - "requires_access_code" → AccessCode-Eingabe zeigen +/// - "requires_tfa" → TFA-Code-Eingabe zeigen (nach AccessCode) +/// - "show_document" → Dokument laden und anzeigen +/// - "already_signed" → Info-Seite "Bereits unterschrieben" +/// - "rejected" → Info-Seite "Abgelehnt" +/// - "not_found" → Fehler-Seite "Nicht gefunden" +/// - "expired" → Fehler-Seite "Link abgelaufen" +/// +public class ReceiverAuthResponse +{ + /// Aktueller Status des Empfänger-Flows + public required string Status { get; init; } + + /// Titel des Umschlags (z.B. "Vertragsdokument") + public string? Title { get; init; } + + /// Nachricht des Absenders + public string? Message { get; init; } + + /// E-Mail des Absenders (für Rückfragen-Hinweis) + public string? SenderEmail { get; init; } + + /// Name des Empfängers + public string? ReceiverName { get; init; } + + /// Ob TFA für diesen Umschlag aktiviert ist + public bool TfaEnabled { get; init; } + + /// Ob der Empfänger eine Telefonnummer hat (für SMS-TFA) + public bool HasPhoneNumber { get; init; } + + /// Ob das Dokument nur gelesen werden soll (ReadAndConfirm) + public bool ReadOnly { get; init; } + + /// TFA-Typ: "sms" oder "authenticator" (wenn Status = "requires_tfa") + public string? TfaType { get; init; } + + /// Ablaufzeit des SMS-Codes (für Countdown-Timer) + public DateTime? TfaExpiration { get; init; } + + /// Fehlermeldung (z.B. "Falscher Zugangscode") + public string? ErrorMessage { get; init; } +} + +/// +/// Request-Body für POST /api/receiverauth/{key}/access-code +/// +public class AccessCodeRequest +{ + /// Der vom Empfänger eingegebene Zugangscode + public required string AccessCode { get; init; } + + /// Ob SMS statt Authenticator bevorzugt wird + public bool PreferSms { get; init; } +} + +/// +/// Request-Body für POST /api/receiverauth/{key}/tfa +/// +public class TfaCodeRequest +{ + /// Der eingegebene TFA-Code (6-stellig) + public required string Code { get; init; } + + /// "sms" oder "authenticator" + public required string Type { get; init; } +} \ No newline at end of file