diff --git a/EnvelopeGenerator.API/Controllers/AuthController.cs b/EnvelopeGenerator.API/Controllers/AuthController.cs index 2ea2daec..6a3500c9 100644 --- a/EnvelopeGenerator.API/Controllers/AuthController.cs +++ b/EnvelopeGenerator.API/Controllers/AuthController.cs @@ -1,3 +1,4 @@ +using DigitalData.Auth.Claims; using EnvelopeGenerator.API.Controllers.Interfaces; using EnvelopeGenerator.API.Models; using EnvelopeGenerator.Domain.Constants; @@ -73,4 +74,44 @@ public partial class AuthController(IOptions authTokenKeyOptions, => role is not null && !User.IsInRole(role) ? Unauthorized() : Ok(); + + /// + /// Checks whether the caller holds a valid per-envelope receiver token for the given envelope key. + /// The request must carry a cookie named AuthTokenSignFLOWReceiver.{envelopeKey}. + /// + /// The unique envelope key extracted from the route. + /// Valid per-envelope token found. + /// Token is missing, expired or invalid. + [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)] + [Authorize(Policy = AuthPolicy.Receiver)] + [HttpGet("check/envelope/{envelopeKey}")] + public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok(); + + /// + /// Removes the per-envelope receiver cookie for the given envelope key. + /// + /// The unique envelope key whose cookie should be deleted. + /// Cookie successfully deleted. + [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [HttpPost("logout/envelope/{envelopeKey}")] + public IActionResult LogoutEnvelopeReceiver([FromRoute] string envelopeKey) + { + var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey); + Response.Cookies.Delete(cookieName); + return Ok(); + } + + /// + /// Removes all per-envelope receiver cookies from the current request. + /// + /// All envelope receiver cookies successfully deleted. + [ProducesResponseType(typeof(void), StatusCodes.Status200OK)] + [HttpPost("logout/envelope")] + public IActionResult LogoutAllEnvelopeReceivers() + { + foreach (var cookieName in Request.Cookies.Keys.Where(k => CookieNames.IsEnvelopeReceiverCookie(k, authTokenKeys.Cookie))) + Response.Cookies.Delete(cookieName); + return Ok(); + } } \ No newline at end of file diff --git a/EnvelopeGenerator.API/Controllers/DocumentController.cs b/EnvelopeGenerator.API/Controllers/DocumentController.cs index 5ad42022..41d43fc2 100644 --- a/EnvelopeGenerator.API/Controllers/DocumentController.cs +++ b/EnvelopeGenerator.API/Controllers/DocumentController.cs @@ -1,3 +1,4 @@ +using DigitalData.Auth.Claims; using EnvelopeGenerator.API.Controllers.Interfaces; using EnvelopeGenerator.API.Extensions; using EnvelopeGenerator.Application.Documents.Queries; @@ -5,6 +6,7 @@ using EnvelopeGenerator.Domain.Constants; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Threading.Channels; namespace EnvelopeGenerator.API.Controllers; @@ -17,7 +19,7 @@ namespace EnvelopeGenerator.API.Controllers; [Authorize] [ApiController] [Route("api/[controller]")] -public class DocumentController(IMediator mediator, IAuthorizationService authService) : ControllerBase, IAuthController +public class DocumentController(IMediator mediator, IAuthorizationService authService, ILogger logger) : ControllerBase, IAuthController { /// /// @@ -60,4 +62,33 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe return Unauthorized(); } + + /// + /// + /// + /// + /// + /// + [HttpGet("{envelopeKey}")] + [Authorize(Policy = AuthPolicy.Receiver)] + public async Task GetDocumentOfReceiver(string envelopeKey, CancellationToken cancel) + { + var envelopeIdStr = User.FindFirst(EnvelopeClaimNames.EnvelopeId)?.Value; + + if (!int.TryParse(envelopeIdStr, out int envelopeId)) + { + logger.LogError( + "Inner service error: Failed to parse Envelope ID from claims. Claim '{ClaimName}' had an invalid or missing value: '{ClaimValue}'.", + EnvelopeClaimNames.EnvelopeId, + envelopeIdStr ?? "null"); + + return StatusCode(StatusCodes.Status500InternalServerError, "Inner service error: Invalid envelope claim."); + } + + var senderDoc = await mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel); + + return senderDoc.ByteData is byte[] senderDocByte + ? File(senderDocByte, "application/octet-stream") + : NotFound("Document is empty."); + } } \ No newline at end of file diff --git a/EnvelopeGenerator.API/Controllers/EnvelopeReceiverController.cs b/EnvelopeGenerator.API/Controllers/EnvelopeReceiverController.cs index db8371ee..251c2738 100644 --- a/EnvelopeGenerator.API/Controllers/EnvelopeReceiverController.cs +++ b/EnvelopeGenerator.API/Controllers/EnvelopeReceiverController.cs @@ -14,6 +14,7 @@ using EnvelopeGenerator.Application.Common.SQL; using EnvelopeGenerator.Application.Common.Dto.Receiver; using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor; using EnvelopeGenerator.API.Extensions; +using EnvelopeGenerator.Domain.Constants; namespace EnvelopeGenerator.API.Controllers; @@ -73,6 +74,24 @@ public class EnvelopeReceiverController : ControllerBase return Ok(result); } + /// + /// + /// + /// + /// + /// + [Authorize(Policy = AuthPolicy.Receiver)] + [HttpGet("{envelopeKey}")] + public async Task GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel) + { + var er = await _mediator.Send(new ReadEnvelopeReceiverQuery() + { + Key = envelopeKey + }, cancel); + + return Ok(er); + } + /// /// Ruft den Namen des zuletzt verwendeten Empfängers basierend auf der angegebenen E-Mail-Adresse ab. /// diff --git a/EnvelopeGenerator.API/Documentation/AuthProxyDocumentFilter.cs b/EnvelopeGenerator.API/Documentation/AuthProxyDocumentFilter.cs index d4ae7480..8cb9c6c0 100644 --- a/EnvelopeGenerator.API/Documentation/AuthProxyDocumentFilter.cs +++ b/EnvelopeGenerator.API/Documentation/AuthProxyDocumentFilter.cs @@ -16,6 +16,12 @@ public sealed class AuthProxyDocumentFilter : IDocumentFilter /// /// public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + AddLoginOperation(swaggerDoc, context); + AddEnvelopeReceiverLoginOperation(swaggerDoc, context); + } + + private static void AddLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context) { const string path = "/api/auth"; @@ -67,4 +73,51 @@ public sealed class AuthProxyDocumentFilter : IDocumentFilter } }; } + + private static void AddEnvelopeReceiverLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + const string path = "/api/Auth/envelope-receiver/{key}"; + + var bodySchema = context.SchemaGenerator.GenerateSchema(typeof(EnvelopeReceiverLogin), context.SchemaRepository); + + var operation = new OpenApiOperation + { + Summary = "Envelope receiver login (auth-hub proxy)", + Description = "Proxies the envelope receiver login to the auth service. " + + "The `cookie` query parameter is always forwarded as `true` so the auth service sets the per-envelope cookie automatically.", + Tags = [new() { Name = "Auth" }], + Parameters = + { + new OpenApiParameter + { + Name = "key", + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema { Type = "string" }, + Description = "The unique envelope receiver key." + } + }, + RequestBody = new OpenApiRequestBody + { + Required = false, + Content = + { + ["multipart/form-data"] = new OpenApiMediaType { Schema = bodySchema } + } + }, + Responses = + { + ["200"] = new OpenApiResponse { Description = "OK – per-envelope cookie set by auth service." }, + ["401"] = new OpenApiResponse { Description = "Unauthorized – invalid or missing access code." } + } + }; + + swaggerDoc.Paths[path] = new OpenApiPathItem + { + Operations = + { + [OperationType.Post] = operation + } + }; + } } \ No newline at end of file diff --git a/EnvelopeGenerator.API/EnvelopeGenerator.API.csproj b/EnvelopeGenerator.API/EnvelopeGenerator.API.csproj index 6a64b510..14acf06c 100644 --- a/EnvelopeGenerator.API/EnvelopeGenerator.API.csproj +++ b/EnvelopeGenerator.API/EnvelopeGenerator.API.csproj @@ -30,6 +30,7 @@ + diff --git a/EnvelopeGenerator.API/Models/EnvelopeReceiverLogin.cs b/EnvelopeGenerator.API/Models/EnvelopeReceiverLogin.cs new file mode 100644 index 00000000..fa53ec61 --- /dev/null +++ b/EnvelopeGenerator.API/Models/EnvelopeReceiverLogin.cs @@ -0,0 +1,7 @@ +namespace EnvelopeGenerator.API.Models; + +/// +/// Request body for the envelope-receiver login endpoint. +/// +/// The access code sent to the receiver. +public record EnvelopeReceiverLogin(string? AccessCode = null); diff --git a/EnvelopeGenerator.API/Program.cs b/EnvelopeGenerator.API/Program.cs index 448d256a..f2478083 100644 --- a/EnvelopeGenerator.API/Program.cs +++ b/EnvelopeGenerator.API/Program.cs @@ -19,6 +19,7 @@ using DigitalData.Core.Abstractions.Security.Extensions; using EnvelopeGenerator.API.Middleware; using NLog.Web; using NLog; +using DigitalData.Auth.Claims; var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); logger.Info("Logging initialized!"); @@ -112,7 +113,7 @@ try }); builder.Services.AddOpenApi(); - //AddEF Core dbcontext + //Add EF Core dbcontext var useDbMigration = Environment.GetEnvironmentVariable("MIGRATION_TEST_MODE") == true.ToString() || config.GetValue("UseDbMigration"); var cnnStrName = useDbMigration ? "DbMigrationTest" : "Default"; var connStr = config.GetConnectionString(cnnStrName) @@ -126,6 +127,9 @@ try var authTokenKeys = config.GetOrDefault(); + // Scheme name used for per-envelope receiver JWT authentication. + const string EnvelopeReceiverScheme = "EnvelopeReceiverJwt"; + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -163,6 +167,61 @@ try return Task.CompletedTask; } }; + }) + // Per-envelope receiver scheme: reads the JWT from the cookie named + // AuthTokenSignFLOWReceiver.{envelope_key} where envelope_key is the + // last path segment of the request URL. + // This enables simultaneous authentication for multiple envelopes + // within the same browser session. + .AddJwtBearer(EnvelopeReceiverScheme, opt => + { + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) => + { + var clientParams = deferredProvider.GetOptions(); + var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience); + return [publicKey.SecurityKey]; + }, + ValidateIssuer = true, + ValidIssuer = authTokenKeys.Issuer, + ValidateAudience = true, + ValidAudience = authTokenKeys.Audience, + }; + + opt.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + + // Derive the envelope key from the last route segment: /{envelope_key} + var envelopeKey = paths?.LastOrDefault(); + + if (envelopeKey is not null) + { + var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey); + if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) && cookieToken is not null) + context.Token = cookieToken; + } + + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + var envelopeKey = paths?.LastOrDefault(); + + var sub = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + ?? context.Principal?.FindFirst("sub")?.Value; + + if (envelopeKey is null || sub != envelopeKey) + context.Fail("Envelope key in the path does not match the token subject."); + + return Task.CompletedTask; + } + }; }); // Authentication @@ -182,8 +241,13 @@ try policy.RequireRole(Role.Sender, Role.Receiver.Full)) .AddPolicy(AuthPolicy.Sender, policy => policy.RequireRole(Role.Sender)) + // Per-envelope policy: uses the dedicated EnvelopeReceiverJwt scheme so it + // never conflicts with the default JwtBearer scheme. .AddPolicy(AuthPolicy.Receiver, policy => - policy.RequireRole(Role.Receiver.Full)) + policy + .AddAuthenticationSchemes(EnvelopeReceiverScheme) + .RequireAuthenticatedUser() + .RequireRole(Role.Receiver.Full, "receiver")) .AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA)); diff --git a/EnvelopeGenerator.API/appsettings.json b/EnvelopeGenerator.API/appsettings.json index 1fcf433e..25bff075 100644 --- a/EnvelopeGenerator.API/appsettings.json +++ b/EnvelopeGenerator.API/appsettings.json @@ -1,6 +1,6 @@ { "UseSwagger": true, - "UseDbMigration": true, + "UseDbMigration": false, "DiPMode": true, "Logging": { "LogLevel": { diff --git a/EnvelopeGenerator.API/yarp.json b/EnvelopeGenerator.API/yarp.json index dc38a955..cd16812a 100644 --- a/EnvelopeGenerator.API/yarp.json +++ b/EnvelopeGenerator.API/yarp.json @@ -10,6 +10,17 @@ "Transforms": [ { "PathSet": "/api/auth/sign-flow" } ] + }, + "auth-envelope-receiver-login": { + "ClusterId": "auth-hub", + "Match": { + "Path": "/api/Auth/envelope-receiver/{key}", + "Methods": [ "POST" ] + }, + "Transforms": [ + { "PathPattern": "/api/auth/envelope-receiver/{key}" }, + { "QueryValueParameter": "cookie", "Set": "true" } + ] } }, "Clusters": {