Compare commits

...

22 Commits

Author SHA1 Message Date
2fcea78574 Add Swagger doc filter for /api/auth proxy login endpoint
Introduced AuthProxyDocumentFilter to programmatically document the POST /api/auth proxy login endpoint in Swagger. The filter defines request body schemas, example values, query parameter, and response codes. Registered the filter in Program.cs for OpenAPI generation.
2026-02-03 11:13:53 +01:00
e8e428f935 Update default Audience value in AuthTokenKeys
Changed the default Audience property in AuthTokenKeys from "sign-flow-gen.digitaldata.works" to "sign-flow.digitaldata.works" to reflect the correct expected audience for authentication tokens.
2026-02-03 11:13:29 +01:00
9450ed3486 Remove old Login endpoint and related documentation
Removed the previous Login method from AuthController, including its XML documentation and Swagger/OpenAPI annotations. This prepares the controller for a revised authentication implementation.
2026-02-03 10:45:17 +01:00
583a07c646 Add YARP reverse proxy support to API project
Integrated YARP by adding the Yarp.ReverseProxy package, including yarp.json for proxy configuration, and updating Program.cs to load and map reverse proxy routes. This enables the API to forward requests based on yarp.json settings.
2026-02-03 10:44:32 +01:00
51ad4fbc2c Add YARP reverse proxy route for auth login requests
Configured yarp.json to proxy POST /api/auth requests to the
auth-hub cluster at http://172.24.12.39:9090, rewriting the
path to /api/auth/sign-flow before forwarding.
2026-02-03 10:39:33 +01:00
50ac7570ea Refactor GetDocument to unify sender and receiver logic
Combined sender and receiver document retrieval into a single
GetDocument endpoint. The endpoint now authorizes both Sender
and Receiver.FullyAuth roles, handling their logic based on
role detection. Sender requires a query parameter; receiver
extracts envelope ID from claims and disallows query params.
Updated method signature and endpoint documentation.
2026-02-03 10:06:03 +01:00
5465996563 Refactor document retrieval endpoints and authorization
- Updated DocumentController to use class-level [Authorize] and method-level role-based authorization for sender and receiver endpoints.
- Replaced ReadEnvelopeReceiverQuery with ReadDocumentQuery for sender document retrieval; simplified response logic.
- Added a new endpoint for fully authenticated receivers to fetch documents by envelope ID from user claims.
- Refactored ReadDocumentQuery and handler to always return DocumentDto, throw NotFoundException when needed, and use _repo.Query.
- Cleaned up using directives and removed legacy error handling and logging.
2026-02-03 09:48:33 +01:00
1b840f4ae3 Refactor AuthController to use primary constructor
Refactored AuthController to use C# 12 primary constructor syntax for ILogger<AuthController> injection. Removed obsolete IUserService and IDirectorySearchService dependencies, their fields, and the old constructor. This streamlines the controller and prepares it for MediatR-based service handling.
2026-02-02 16:29:31 +01:00
3923a3b403 Refactor claim retrieval with GetRequiredClaimOfSender
Added a private extension method GetRequiredClaimOfSender to ClaimsPrincipal for retrieving the first available value from multiple claim types, throwing a detailed exception if none are found. Refactored GetId to use this method, improving code reuse and clarity when handling user claims.
2026-02-02 16:27:45 +01:00
ada621ac46 Refactor claim access to enforce required user claims
Replaced nullable claim accessors with strict versions that throw exceptions if required claims are missing or invalid. Updated controller logic to use new methods and removed fallback/error handling for missing claims, ensuring stricter claim validation throughout the codebase.
2026-02-02 16:17:53 +01:00
abbe6a26a9 Refactor ControllerExtensions to SenderClaimExtensions
Renamed the extension class for claims handling and added a private GetRequiredClaimOfSender method for ClaimsPrincipal. This method throws a detailed exception when a required claim is missing, improving error reporting and debugging.
2026-02-02 16:11:29 +01:00
3066dac541 Rename EnvelopeAuthExtensions to ReceiverClaimExtensions
Refactored the class name in ReceiverClaimExtensions.cs to better reflect its focus on receiver-specific claim extension methods rather than general envelope authentication. No functional changes were made.
2026-02-02 15:58:47 +01:00
b1aa6d6639 Refactor claim extraction methods for receiver context
Renamed authentication-related extension methods to clarify that they extract claims for the "receiver" context (e.g., GetAuthReceiverSignature → GetReceiverSignatureOfReceiver). Updated all usages in AnnotationController and ReadOnlyController. Also renamed the helper method GetRequiredClaim to GetRequiredClaimOfReceiver for improved clarity and reduced ambiguity.
2026-02-02 15:58:07 +01:00
31fe1c34f2 Remove GetClaimValue extension from EnvelopeAuthExtensions
The GetClaimValue method, which delegated to GetRequiredClaim for retrieving claim values by type, has been removed from the EnvelopeAuthExtensions class. Other functionality in the class remains unchanged.
2026-02-02 15:13:10 +01:00
d7644bfe07 Move ClaimsPrincipal extensions to API.Extensions namespace
Refactored ControllerExtensions: moved user claim extraction
methods from EnvelopeGenerator.API.Controllers to the new
EnvelopeGenerator.API.Extensions namespace. Updated all
references and using statements accordingly. No logic changes;
improves code organization and clarity.
2026-02-02 15:07:27 +01:00
4759b16a85 Mark GetAnnotationParams as obsolete (PSPDF Kit deprecated)
Added [Obsolete] attribute to GetAnnotationParams in ConfigController to indicate that PSPDF Kit will no longer be used and the method is deprecated. This warns developers to avoid using this method in future development.
2026-02-02 15:05:15 +01:00
cfdfb43631 Restrict annotation endpoints to Receiver.FullyAuth role
Updated [Authorize] attributes to require Receiver.FullyAuth role on AnnotationController and relevant methods. Removed redundant claim checks now enforced by role-based authorization. Clarified [Obsolete] message for PSPDF Kit endpoint.
2026-02-02 14:55:44 +01:00
6254bb6e3f Update auth role and envelopeId check in CreateAsync
Changed [Authorize] to require Receiver.FullyAuth role for CreateAsync, restricting access to receiver users. Removed explicit null check and logging for envelopeId claim, allowing the method to proceed without this validation.
2026-02-02 14:55:10 +01:00
f995fa9fc3 Refactor claim accessors to enforce required claims
Refactored EnvelopeAuthExtensions to require presence of all key authentication claims. Added GetRequiredClaim helper that throws detailed exceptions if claims are missing or invalid, replacing nullable return types with non-nullable ones. This ensures authentication logic fails fast and provides clearer error messages when claims are misconfigured or tampered with.
2026-02-02 14:54:59 +01:00
c2fefe798d Add Sender constant to Role in Domain.Constants
Added a new Sender constant to the Role class within the EnvelopeGenerator.Domain.Constants namespace, allowing it to be used alongside existing Receiver role constants.
2026-02-02 11:58:30 +01:00
849a282ec5 Refactor Role constants; add Receiver class, mark obsolete
Refactored the Role class by introducing a nested static Receiver class containing PreAuth and FullyAuth constants. Marked the original Role.PreAuth and Role.FullyAuth as [Obsolete] with guidance to use the new Receiver constants. Added a conditional using directive for System for NETFRAMEWORK compatibility.
2026-02-02 11:55:17 +01:00
6b23dcdba7 Refactor: unify role constants under new Role class
Replaced all usages of ReceiverRole with the new Role class in EnvelopeGenerator.Domain.Constants. Removed ReceiverRole.cs and added Role.cs with PreAuth and FullyAuth constants. Updated all [Authorize] attributes and role checks in controllers and authentication logic to use Role.FullyAuth and Role.PreAuth. This centralizes role management for improved maintainability and clarity.
2026-02-02 11:53:26 +01:00
24 changed files with 329 additions and 254 deletions

View File

@@ -18,7 +18,7 @@ namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages annotations and signature lifecycle for envelopes.
/// </summary>
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.Receiver.FullyAuth)]
[ApiController]
[Route("api/[controller]")]
public class AnnotationController : ControllerBase
@@ -54,19 +54,13 @@ public class AnnotationController : ControllerBase
/// </summary>
/// <param name="psPdfKitAnnotation">Annotation payload.</param>
/// <param name="cancel">Cancellation token.</param>
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.Receiver.FullyAuth)]
[HttpPost]
[Obsolete("This endpoint is for PSPDF Kit.")]
[Obsolete("PSPDF Kit will no longer be used.")]
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
{
var signature = User.GetAuthReceiverSignature();
var uuid = User.GetAuthEnvelopeUuid();
if (signature is null || uuid is null)
{
_logger.LogError("Authorization failed: authenticated user does not have a valid signature or envelope UUID.");
return Unauthorized("User authentication is incomplete. Missing required claims for processing this request.");
}
var signature = User.GetReceiverSignatureOfReceiver();
var uuid = User.GetEnvelopeUuidOfReceiver();
var envelopeReceiver = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel).ThrowIfNull(Exceptions.NotFound);
@@ -93,20 +87,14 @@ public class AnnotationController : ControllerBase
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.Receiver.FullyAuth)]
[HttpPost("reject")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
{
var signature = User.GetAuthReceiverSignature();
var uuid = User.GetAuthEnvelopeUuid();
var mail = User.GetAuthReceiverMail();
if (uuid is null || signature is null || mail is null)
{
_logger.LogEnvelopeError(uuid: uuid, signature: signature,
message: @$"Unauthorized POST request in api\\envelope\\reject. One of claims, Envelope, signature or mail ({mail}) is null.");
return Unauthorized();
}
var signature = User.GetReceiverSignatureOfReceiver();
var uuid = User.GetEnvelopeUuidOfReceiver();
var mail = User.GetReceiverMailOfReceiver();
var envRcvRes = await _envelopeReceiverService.ReadByUuidSignatureAsync(uuid: uuid, signature: signature);

View File

@@ -1,5 +1,3 @@
using DigitalData.Core.Abstraction.Application;
using DigitalData.UserManager.Application.Contracts;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
@@ -11,94 +9,11 @@ namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller verantwortlich für die Benutzer-Authentifizierung, einschließlich Anmelden, Abmelden und Überprüfung des Authentifizierungsstatus.
/// </summary>
/// <param name="logger"></param>
[Route("api/[controller]")]
[ApiController]
public partial class AuthController : ControllerBase
public partial class AuthController(ILogger<AuthController> logger) : ControllerBase
{
private readonly ILogger<AuthController> _logger;
[Obsolete("Use MediatR")]
private readonly IUserService _userService;
private readonly IDirectorySearchService _dirSearchService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthController"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="userService">The user service instance.</param>
/// <param name="dirSearchService">The directory search service instance.</param>
[Obsolete("Use MediatR")]
public AuthController(ILogger<AuthController> logger, IUserService userService, IDirectorySearchService dirSearchService)
{
_logger = logger;
_userService = userService;
_dirSearchService = dirSearchService;
}
/// <summary>
/// Authentifiziert einen Benutzer und generiert ein JWT-Token. Wenn 'cookie' wahr ist, wird das Token als HTTP-Only-Cookie zurückgegeben.
/// </summary>
/// <param name="login">Benutzeranmeldedaten (Benutzername und Passwort).</param>
/// <param name="cookie">Wenn wahr, wird das JWT-Token auch als HTTP-Only-Cookie gesendet.</param>
/// <returns>
/// Gibt eine HTTP 200 oder 401.
/// </returns>
/// <remarks>
/// Sample request:
///
/// POST /api/auth?cookie=true
/// {
/// "username": "MaxMustermann",
/// "password": "Geheim123!"
/// }
///
/// POST /api/auth?cookie=true
/// {
/// "id": "1",
/// "password": "Geheim123!"
/// }
///
/// </remarks>
/// <response code="200">Erfolgreiche Anmeldung. Gibt das JWT-Token im Antwortkörper oder als Cookie zurück, wenn 'cookie' wahr ist.</response>
/// <response code="401">Unbefugt. Ungültiger Benutzername oder Passwort.</response>
[ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/javascript")]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[AllowAnonymous]
[HttpPost]
public Task<IActionResult> Login([FromBody] Login login, [FromQuery] bool cookie = false)
{
// added to configure open API (swagger and scalar)
throw new NotImplementedException();
}
/// <summary>
/// Authentifiziert einen Benutzer und generiert ein JWT-Token. Das Token wird als HTTP-only-Cookie zurückgegeben.
/// </summary>
/// <param name="login">Benutzeranmeldedaten (Benutzername und Passwort).</param>
/// <returns>
/// Gibt eine HTTP 200 oder 401.
/// </returns>
/// <remarks>
/// Sample request:
///
/// POST /api/auth/form
/// {
/// "username": "MaxMustermann",
/// "password": "Geheim123!"
/// }
///
/// </remarks>
/// <response code="200">Erfolgreiche Anmeldung. Gibt das JWT-Token im Antwortkörper oder als Cookie zurück, wenn 'cookie' wahr ist.</response>
/// <response code="401">Unbefugt. Ungültiger Benutzername oder Passwort.</response>
[ProducesResponseType(typeof(string), StatusCodes.Status200OK, "text/javascript")]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[AllowAnonymous]
[HttpPost]
[Route("form")]
public Task<IActionResult> Login([FromForm] Login login)
{
// added to configure open API (swagger and scalar)
throw new NotImplementedException();
}
/// <summary>
/// Entfernt das Authentifizierungs-Cookie des Benutzers (AuthCookie)

View File

@@ -22,6 +22,7 @@ public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParams
/// Returns annotation configuration that was previously rendered by MVC.
/// </summary>
[HttpGet("Annotations")]
[Obsolete("PSPDF Kit will no longer be used.")]
public IActionResult GetAnnotationParams()
{
return Ok(_annotationParams.AnnotationJSObject);

View File

@@ -1,62 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace EnvelopeGenerator.API.Controllers
{
/// <summary>
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
/// </summary>
public static class ControllerExtensions
{
/// <summary>
/// Attempts to retrieve the user's ID from the claims. Returns null if the ID is not found or invalid.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The user's ID as an integer, or null if not found or invalid.</returns>
public static int? GetIdOrDefault(this ClaimsPrincipal user)
=> int.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub"), out int result)
? result : null;
/// <summary>
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The user's ID as an integer.</returns>
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
public static int GetId(this ClaimsPrincipal user)
=> user.GetIdOrDefault()
?? throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
/// <summary>
/// Retrieves the username from the claims, if available.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The username as a string, or null if not found.</returns>
public static string? GetUsernameOrDefault(this ClaimsPrincipal user)
=> user.FindFirst(ClaimTypes.Name)?.Value;
/// <summary>
/// Retrieves the user's surname (last name) from the claims, if available.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The surname as a string, or null if not found.</returns>
public static string? GetNameOrDefault(this ClaimsPrincipal user)
=> user.FindFirst(ClaimTypes.Surname)?.Value;
/// <summary>
/// Retrieves the user's given name (first name) from the claims, if available.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The given name as a string, or null if not found.</returns>
public static string? GetPrenameOrDefault(this ClaimsPrincipal user)
=> user.FindFirst(ClaimTypes.GivenName)?.Value;
/// <summary>
/// Retrieves the user's email address from the claims, if available.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The email address as a string, or null if not found.</returns>
public static string? GetEmailOrDefault(this ClaimsPrincipal user)
=> user.FindFirst(ClaimTypes.Email)?.Value;
}
}

View File

@@ -1,6 +1,5 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -14,30 +13,45 @@ namespace EnvelopeGenerator.API.Controllers;
/// <remarks>
/// Initializes a new instance of the <see cref="DocumentController"/> class.
/// </remarks>
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DocumentController(IMediator mediator, ILogger<DocumentController> logger) : ControllerBase
{
/// <summary>
/// Returns the document bytes for the specified envelope receiver key.
/// Returns the document bytes receiver.
/// </summary>
/// <param name="query">Encoded envelope key.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
public async Task<IActionResult> GetDocument(ReadEnvelopeReceiverQuery query, CancellationToken cancel)
[Authorize(Roles = $"{Role.Sender},{Role.Receiver.FullyAuth}")]
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
{
var envRcv = await mediator.Send(query, cancel).FirstAsync(Exceptions.NotFound);
var byteData = envRcv.Envelope?.Documents?.FirstOrDefault()?.ByteData;
if (byteData is null || byteData.Length == 0)
// Sender: expects query with envelope key
if (User.IsInRole(Role.Sender))
{
logger.LogError("Document byte data is null or empty for envelope-receiver entity:\n{envelopeKey}.",
envRcv.ToJson(Format.Json.ForDiagnostics));
throw new NotFoundException("Document is empty.");
if (query is null)
return BadRequest("Missing document query.");
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
return File(byteData, "application/octet-stream");
// Receiver: resolve envelope id from claims
if (User.IsInRole(Role.Receiver.FullyAuth))
{
if (query is not null)
return BadRequest("Query parameters are not allowed for receiver role.");
var envelopeId = User.GetEnvelopeIdOfReceiver();
var receiverDoc = await mediator.Send(new ReadDocumentQuery { EnvelopeId = envelopeId }, cancel);
return receiverDoc.ByteData is byte[] receiverDocByte
? File(receiverDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
return Unauthorized();
}
}
}

View File

@@ -1,4 +1,5 @@
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Application.Envelopes.Queries;
using MediatR;
using Microsoft.AspNetCore.Authorization;

View File

@@ -13,6 +13,7 @@ using System.Data;
using EnvelopeGenerator.Application.Common.SQL;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.API.Extensions;
namespace EnvelopeGenerator.API.Controllers;
@@ -65,16 +66,7 @@ public class EnvelopeReceiverController : ControllerBase
[HttpGet]
public async Task<IActionResult> GetEnvelopeReceiver([FromQuery] ReadEnvelopeReceiverQuery envelopeReceiver)
{
var username = User.GetUsernameOrDefault();
if (username is null)
{
_logger.LogError(@"Envelope Receiver dto cannot be sent because username claim is null. Potential authentication and authorization error. The value of other claims are [id: {id}], [username: {username}], [name: {name}], [prename: {prename}], [email: {email}].",
User.GetId(), User.GetUsernameOrDefault(), User.GetNameOrDefault(), User.GetPrenameOrDefault(), User.GetEmailOrDefault());
return StatusCode(StatusCodes.Status500InternalServerError);
}
envelopeReceiver = envelopeReceiver with { Username = username };
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
var result = await _mediator.Send(envelopeReceiver);

View File

@@ -37,22 +37,17 @@ public class ReadOnlyController : ControllerBase
/// </summary>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.Receiver.FullyAuth)]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
{
var authReceiverMail = User.GetAuthReceiverMail();
var authReceiverMail = User.GetReceiverMailOfReceiver();
if (authReceiverMail is null)
{
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
}
var envelopeId = User.GetAuthEnvelopeId();
if (envelopeId is null)
{
_logger.LogError("Envelope Id claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
}
var envelopeId = User.GetEnvelopeIdOfReceiver();
createDto.AddedWho = authReceiverMail;
createDto.EnvelopeId = envelopeId;

View File

@@ -111,7 +111,7 @@ public class TfaRegistrationController : ControllerBase
/// <summary>
/// Logs out the envelope receiver from cookie authentication.
/// </summary>
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[HttpPost("auth/logout")]
public async Task<IActionResult> LogOutAsync()
{

View File

@@ -0,0 +1,70 @@
using EnvelopeGenerator.API.Models;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace EnvelopeGenerator.API.Documentation;
/// <summary>
///
/// </summary>
public sealed class AuthProxyDocumentFilter : IDocumentFilter
{
/// <summary>
///
/// </summary>
/// <param name="swaggerDoc"></param>
/// <param name="context"></param>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
const string path = "/api/auth";
var loginSchema = context.SchemaGenerator.GenerateSchema(typeof(Login), context.SchemaRepository);
var loginExample = new OpenApiObject
{
["password"] = new OpenApiString(""),
["username"] = new OpenApiString("")
};
var operation = new OpenApiOperation
{
Summary = "Proxy login (auth-hub)",
Description = "Proxies the request to the auth service. Add query parameter `cookie=true|false`.",
Tags = [new() { Name = "Auth" }],
Parameters =
{
new OpenApiParameter
{
Name = "cookie",
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = "boolean", Default = new OpenApiBoolean(true) },
Example = new OpenApiBoolean(true),
Description = "If true, auth service sets the auth cookie."
}
},
RequestBody = new OpenApiRequestBody
{
Required = true,
Content =
{
["application/json"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample },
["multipart/form-data"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample }
}
},
Responses =
{
["200"] = new OpenApiResponse { Description = "OK (proxied response)" },
["401"] = new OpenApiResponse { Description = "Unauthorized" }
}
};
swaggerDoc.Paths[path] = new OpenApiPathItem
{
Operations =
{
[OperationType.Post] = operation
}
};
}
}

View File

@@ -24,6 +24,10 @@
<None Remove="ClientApp\**" />
</ItemGroup>
<ItemGroup>
<None Include="yarp.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.Scalar" Version="1.1.8" />
<PackageReference Include="DigitalData.Auth.Client" Version="1.3.7" />
@@ -35,6 +39,7 @@
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">

View File

@@ -1,3 +1,4 @@
using System.Linq;
using System.Security.Claims;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using Microsoft.AspNetCore.Authentication;
@@ -8,48 +9,61 @@ namespace EnvelopeGenerator.API.Extensions;
/// <summary>
/// Provides helper methods for working with envelope-specific authentication claims.
/// </summary>
public static class EnvelopeAuthExtensions
public static class ReceiverClaimExtensions
{
/// <summary>
/// Retrieves a claim value by type.
/// </summary>
/// <param name="user">The current claims principal.</param>
/// <param name="claimType">The claim type to resolve.</param>
/// <returns>The claim value or null when missing.</returns>
public static string? GetClaimValue(this ClaimsPrincipal user, string claimType) => user.FindFirstValue(claimType);
private static string GetRequiredClaimOfReceiver(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Gets the authenticated envelope UUID from the claims.
/// </summary>
public static string? GetAuthEnvelopeUuid(this ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.NameIdentifier);
public static string GetEnvelopeUuidOfReceiver(this ClaimsPrincipal user) => user.GetRequiredClaimOfReceiver(ClaimTypes.NameIdentifier);
/// <summary>
/// Gets the authenticated receiver signature from the claims.
/// </summary>
public static string? GetAuthReceiverSignature(this ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.Hash);
public static string GetReceiverSignatureOfReceiver(this ClaimsPrincipal user) => user.GetRequiredClaimOfReceiver(ClaimTypes.Hash);
/// <summary>
/// Gets the authenticated receiver display name from the claims.
/// </summary>
public static string? GetAuthReceiverName(this ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.Name);
public static string GetReceiverNameOfReceiver(this ClaimsPrincipal user) => user.GetRequiredClaimOfReceiver(ClaimTypes.Name);
/// <summary>
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string? GetAuthReceiverMail(this ClaimsPrincipal user) => user.FindFirstValue(ClaimTypes.Email);
public static string GetReceiverMailOfReceiver(this ClaimsPrincipal user) => user.GetRequiredClaimOfReceiver(ClaimTypes.Email);
/// <summary>
/// Gets the authenticated envelope title from the claims.
/// </summary>
public static string? GetAuthEnvelopeTitle(this ClaimsPrincipal user) => user.FindFirstValue(EnvelopeClaimTypes.Title);
public static string GetEnvelopeTitleOfReceiver(this ClaimsPrincipal user) => user.GetRequiredClaimOfReceiver(EnvelopeClaimTypes.Title);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
/// </summary>
public static int? GetAuthEnvelopeId(this ClaimsPrincipal user)
public static int GetEnvelopeIdOfReceiver(this ClaimsPrincipal user)
{
var envIdStr = user.FindFirstValue(EnvelopeClaimTypes.Id);
return int.TryParse(envIdStr, out var envId) ? envId : null;
var envIdStr = user.GetRequiredClaimOfReceiver(EnvelopeClaimTypes.Id);
if (!int.TryParse(envIdStr, out var envId))
{
throw new InvalidOperationException($"Claim '{EnvelopeClaimTypes.Id}' is not a valid integer.");
}
return envId;
}
/// <summary>
@@ -84,4 +98,4 @@ public static class EnvelopeAuthExtensions
new ClaimsPrincipal(claimsIdentity),
authProperties);
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Security.Claims;
namespace EnvelopeGenerator.API.Extensions
{
/// <summary>
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
/// </summary>
public static class SenderClaimExtensions
{
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, params string[] claimTypes)
{
string? value = null;
foreach (var claimType in claimTypes)
{
value = user.FindFirstValue(claimType);
if (value is not null)
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim among [{string.Join(", ", claimTypes)}] is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The user's ID as an integer.</returns>
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
public static int GetId(this ClaimsPrincipal user)
{
var idValue = user.GetRequiredClaimOfSender(ClaimTypes.NameIdentifier, "sub");
if (!int.TryParse(idValue, out var result))
{
throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
}
return result;
}
/// <summary>
/// Retrieves the username from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The username as a string.</returns>
public static string GetUsername(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Name);
/// <summary>
/// Retrieves the user's surname (last name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The surname as a string.</returns>
public static string GetName(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Surname);
/// <summary>
/// Retrieves the user's given name (first name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The given name as a string.</returns>
public static string GetPrename(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.GivenName);
/// <summary>
/// Retrieves the user's email address from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The email address as a string.</returns>
public static string GetEmail(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Email);
}
}

View File

@@ -24,5 +24,5 @@ public class AuthTokenKeys
/// <summary>
/// Gets the expected audience value for the authentication token.
/// </summary>
public string Audience { get; init; } = "sign-flow-gen.digitaldata.works";
public string Audience { get; init; } = "sign-flow.digitaldata.works";
}

View File

@@ -26,6 +26,8 @@ try
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
if (!builder.Environment.IsDevelopment())
@@ -39,6 +41,8 @@ try
var deferredProvider = new DeferredServiceProvider();
builder.Services.AddControllers();
builder.Services.AddHttpClient();
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
// CORS Policy
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
@@ -102,6 +106,8 @@ try
{
options.IncludeXmlComments(xmlFile);
}
options.DocumentFilter<EnvelopeGenerator.API.Documentation.AuthProxyDocumentFilter>();
});
builder.Services.AddOpenApi();
@@ -241,6 +247,7 @@ try
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,25 @@
{
"ReverseProxy": {
"Routes": {
"auth-login": {
"ClusterId": "auth-hub",
"Match": {
"Path": "/api/auth",
"Methods": [ "POST" ]
},
"Transforms": [
{ "PathSet": "/api/auth/sign-flow" }
]
}
},
"Clusters": {
"auth-hub": {
"Destinations": {
"primary": {
"Address": "http://172.24.12.39:9090"
}
}
}
}
}
}

View File

@@ -4,6 +4,7 @@ using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using AutoMapper;
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.Core.Exceptions;
namespace EnvelopeGenerator.Application.Documents.Queries;
@@ -12,14 +13,14 @@ namespace EnvelopeGenerator.Application.Documents.Queries;
/// </summary>
/// <param name="Id">The unique identifier of the document. Optional.</param>
/// <param name="EnvelopeId">The identifier of the envelope associated with the document. Optional.</param>
public record ReadDocumentQuery(int? Id = null, int? EnvelopeId = null) : IRequest<DocumentDto?>
public record ReadDocumentQuery(int? Id = null, int? EnvelopeId = null) : IRequest<DocumentDto>
{
}
/// <summary>
/// Handles queries for reading <see cref="Document"/> data based on either the document ID or the envelope ID.
/// </summary>
public class ReadDocumentQueryHandler : IRequestHandler<ReadDocumentQuery, DocumentDto?>
public class ReadDocumentQueryHandler : IRequestHandler<ReadDocumentQuery, DocumentDto>
{
/// <summary>
/// TempRepo for accessing <see cref="Document"/> entities.
@@ -50,20 +51,19 @@ public class ReadDocumentQueryHandler : IRequestHandler<ReadDocumentQuery, Docum
/// <exception cref="InvalidOperationException">
/// Thrown when neither <see cref="ReadDocumentQuery.Id"/> nor <see cref="ReadDocumentQuery.EnvelopeId"/> is provided.
/// </exception>
public async Task<DocumentDto?> Handle(ReadDocumentQuery query, CancellationToken cancel)
public async Task<DocumentDto> Handle(ReadDocumentQuery query, CancellationToken cancel)
{
if (query.Id is not null)
{
var doc = await _repo.ReadOnly().Where(d => d.Id == query.Id).FirstOrDefaultAsync(cancel);
var doc = await _repo.Query.Where(d => d.Id == query.Id).FirstOrDefaultAsync(cancel);
return _mapper.Map<DocumentDto>(doc);
}
else if (query.EnvelopeId is not null)
{
var doc = await _repo.ReadOnly().Where(d => d.EnvelopeId == query.EnvelopeId).FirstOrDefaultAsync(cancel);
var doc = await _repo.Query.Where(d => d.EnvelopeId == query.EnvelopeId).FirstOrDefaultAsync(cancel);
return _mapper.Map<DocumentDto>(doc);
}
throw new InvalidOperationException(
$"Invalid {nameof(ReadDocumentQuery)}: either {nameof(query.Id)} or {nameof(query.EnvelopeId)} must be provided.");
throw new NotFoundException();
}
}

View File

@@ -1,8 +0,0 @@
namespace EnvelopeGenerator.Domain.Constants
{
public static class ReceiverRole
{
public const string PreAuth = "PreAuth";
public const string FullyAuth = "FullyAuth";
}
}

View File

@@ -0,0 +1,23 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Constants
{
public static class Role
{
[Obsolete("Use Receiver.PreAuth or Receiver.FullyAuth")]
public const string PreAuth = "PreAuth";
[Obsolete("Use Receiver.PreAuth or Receiver.FullyAuth")]
public const string FullyAuth = "FullyAuth";
public static class Receiver
{
public const string PreAuth = "PreAuth";
public const string FullyAuth = "FullyAuth";
}
public const string Sender = "Sender";
}
}

View File

@@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Web.Controllers;
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[ApiController]
[Route("api/[controller]")]
public class AnnotationController : ControllerBase
@@ -42,7 +42,7 @@ public class AnnotationController : ControllerBase
_logger = logger;
}
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[HttpPost]
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
{
@@ -80,7 +80,7 @@ public class AnnotationController : ControllerBase
return Ok();
}
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[HttpPost("reject")]
[Obsolete("Use DigitalData.Core.Exceptions and .Middleware")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)

View File

@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Web.Controllers;
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[ApiController]
[Route("api/[controller]")]
public class DocumentController : ControllerBase

View File

@@ -107,7 +107,7 @@ public class EnvelopeController : ViewControllerBase
return this.ViewEnvelopeNotFound();
}
var er_secret = er_secret_res.Data;
await HttpContext.SignInEnvelopeAsync(er_secret, ReceiverRole.FullyAuth);
await HttpContext.SignInEnvelopeAsync(er_secret, Role.FullyAuth);
return await CreateShowEnvelopeView(er_secret);
}
#endregion UseAccessCode
@@ -172,7 +172,7 @@ public class EnvelopeController : ViewControllerBase
}
// show envelope if already logged in
if (User.IsInRole(ReceiverRole.FullyAuth))
if (User.IsInRole(Role.FullyAuth))
return await CreateShowEnvelopeView(er_secret);
if (auth.HasMulti)
@@ -206,7 +206,7 @@ public class EnvelopeController : ViewControllerBase
.WithData("ErrorMessage", _localizer.WrongEnvelopeReceiverId());
}
await HttpContext.SignInEnvelopeAsync(er_secret, ReceiverRole.FullyAuth);
await HttpContext.SignInEnvelopeAsync(er_secret, Role.FullyAuth);
return await CreateShowEnvelopeView(er_secret);
}
@@ -225,9 +225,9 @@ public class EnvelopeController : ViewControllerBase
&& uuidClaim == er.Envelope?.Uuid
&& signatureClaim is not null
&& signatureClaim == er.Receiver?.Signature
&& User.IsInRole(ReceiverRole.FullyAuth))
&& User.IsInRole(Role.FullyAuth))
{
await HttpContext.SignInEnvelopeAsync(er, ReceiverRole.FullyAuth);
await HttpContext.SignInEnvelopeAsync(er, Role.FullyAuth);
//add PSPDFKit licence key
ViewData["PSPDFKitLicenseKey"] = _configuration["PSPDFKitLicenseKey"];
@@ -262,7 +262,7 @@ public class EnvelopeController : ViewControllerBase
return this.ViewDocumentNotFound();
}
await HttpContext.SignInEnvelopeAsync(er, ReceiverRole.FullyAuth);
await HttpContext.SignInEnvelopeAsync(er, Role.FullyAuth);
ViewData["ReadAndConfirm"] = er.Envelope.ReadOnly;
@@ -334,7 +334,7 @@ public class EnvelopeController : ViewControllerBase
await _rcvService.UpdateAsync(rcv);
}
await HttpContext.SignInEnvelopeAsync(er_secret, ReceiverRole.PreAuth);
await HttpContext.SignInEnvelopeAsync(er_secret, Role.PreAuth);
return await TFAViewAsync(auth.UserSelectSMS, er_secret, envelopeReceiverId);
}
@@ -348,7 +348,7 @@ public class EnvelopeController : ViewControllerBase
if (er_secret.Receiver!.TotpSecretkey is null)
throw new InvalidOperationException($"TotpSecretkey of DTO cannot validate without TotpSecretkey. Dto: {JsonConvert.SerializeObject(er_secret)}");
if (!User.IsInRole(ReceiverRole.PreAuth) || !_envSmsHandler.VerifyTotp(auth.SmsCode!, er_secret.Receiver.TotpSecretkey))
if (!User.IsInRole(Role.PreAuth) || !_envSmsHandler.VerifyTotp(auth.SmsCode!, er_secret.Receiver.TotpSecretkey))
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
ViewData["ErrorMessage"] = _localizer.WrongAccessCode();
@@ -364,7 +364,7 @@ public class EnvelopeController : ViewControllerBase
if (er_secret.Receiver!.TotpSecretkey is null)
throw new InvalidOperationException($"TotpSecretkey of DTO cannot validate without TotpSecretkey. Dto: {JsonConvert.SerializeObject(er_secret)}");
if (!User.IsInRole(ReceiverRole.PreAuth) || !_authenticator.VerifyTotp(auth.AuthenticatorCode!, er_secret.Receiver.TotpSecretkey, window: VerificationWindow.RfcSpecifiedNetworkDelay))
if (!User.IsInRole(Role.PreAuth) || !_authenticator.VerifyTotp(auth.AuthenticatorCode!, er_secret.Receiver.TotpSecretkey, window: VerificationWindow.RfcSpecifiedNetworkDelay))
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
ViewData["ErrorMessage"] = _localizer.WrongAccessCode();

View File

@@ -34,7 +34,7 @@ namespace EnvelopeGenerator.Web.Controllers
}
[HttpPost]
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
{

View File

@@ -91,7 +91,7 @@ public class TFARegController : ViewControllerBase
}
}
[Authorize(Roles = ReceiverRole.FullyAuth)]
[Authorize(Roles = Role.FullyAuth)]
[HttpPost("auth/logout")]
public async Task<IActionResult> LogOut()
{