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