From 69e29d4f6d8c67c57003fa4ebb7988847920a67e Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 12 May 2026 16:24:02 -0400 Subject: [PATCH] feat(altcha): add Svrnty.CQRS.Altcha core check + DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Altcha authorization check, plugged into the ICommandAuthorizationCheck / IQueryAuthorizationCheck seam. Behavior - Self-applies: returns Allowed for any request whose type isn't decorated with [Altcha]. No-op for the 99% of endpoints that don't need PoW. - Reads ctx.Items["mobile_attested"] for Phase 3 bypass when the attribute's AllowMobileAttestationBypass is true. - Pulls the solution off the request via IHasAltchaSolution and delegates verification to IAltchaVerifier (resolved per-call from the request scope, so any verifier lifetime works). - Stashes a diagnostic reason in ctx.Items["altcha_reason"] (missing / misconfigured / invalid / replayed / expired / etc.) for downstream middleware to surface in error responses. - Singleton itself — stateless; one instance shared via factory registrations under both check interfaces. AddSvrntyAltcha() registers the check. The verifier is provided by a transport-specific module (e.g. Svrnty.CQRS.Altcha.Grpc, next). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AltchaAuthorizationCheck.cs | 93 +++++++++++++++++++ .../ServiceCollectionExtensions.cs | 30 ++++++ Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj | 39 ++++++++ Svrnty.CQRS.sln | 14 +++ 4 files changed, 176 insertions(+) create mode 100644 Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs create mode 100644 Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs create mode 100644 Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj diff --git a/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs b/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs new file mode 100644 index 0000000..8b74d3a --- /dev/null +++ b/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Svrnty.CQRS.Abstractions.Security; +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha; + +/// +/// The single check that powers the . Plays both +/// and +/// roles so a request payload of either kind is gated identically. +/// +/// +/// Self-applying: the check no-ops (returns ) +/// for any request whose type isn't decorated with . +/// Resolves from the request scope per call so the +/// check is agnostic to the verifier's lifetime. +/// +public sealed class AltchaAuthorizationCheck : ICommandAuthorizationCheck, IQueryAuthorizationCheck +{ + internal const string ReasonItemKey = "altcha_reason"; + internal const string MobileAttestedItemKey = "mobile_attested"; + + private readonly ILogger _logger; + + public AltchaAuthorizationCheck(ILogger logger) + { + _logger = logger; + } + + public Task CheckAsync( + CommandAuthorizationCheckContext context, + CancellationToken cancellationToken = default) + => CheckCoreAsync(context, context.CommandType, context.Command, cancellationToken); + + public Task CheckAsync( + QueryAuthorizationCheckContext context, + CancellationToken cancellationToken = default) + => CheckCoreAsync(context, context.QueryType, context.Query, cancellationToken); + + private async Task CheckCoreAsync( + AuthorizationCheckContext context, + Type subjectType, + object subject, + CancellationToken cancellationToken) + { + var altchaAttr = subjectType.GetCustomAttribute(inherit: false); + if (altchaAttr is null) + return AuthorizationResult.Allowed; + + if (altchaAttr.AllowMobileAttestationBypass + && context.Items.TryGetValue(MobileAttestedItemKey, out var attested) + && attested is true) + { + _logger.LogDebug("Altcha bypassed for {Type}: mobile attestation present.", subjectType.FullName); + return AuthorizationResult.Allowed; + } + + if (subject is not IHasAltchaSolution carrier) + { + // Developer error: [Altcha] on a request that doesn't carry the solution field. + _logger.LogError( + "[Altcha] is set on {Type} but the type does not implement IHasAltchaSolution. " + + "Verification cannot proceed — treating as forbidden.", + subjectType.FullName); + context.Items[ReasonItemKey] = "misconfigured"; + return AuthorizationResult.Forbidden; + } + + if (string.IsNullOrWhiteSpace(carrier.AltchaSolution)) + { + _logger.LogWarning("Altcha required for {Type} but no solution was supplied.", subjectType.FullName); + context.Items[ReasonItemKey] = "missing"; + return AuthorizationResult.Unauthorized; + } + + var verifier = context.Services.GetRequiredService(); + var result = await verifier.VerifyAsync(carrier.AltchaSolution, cancellationToken); + + if (!result.Ok) + { + _logger.LogWarning( + "Altcha verification rejected {Type}: reason={Reason}", + subjectType.FullName, + result.Reason ?? "unspecified"); + context.Items[ReasonItemKey] = result.Reason ?? "invalid"; + return AuthorizationResult.Unauthorized; + } + + return AuthorizationResult.Allowed; + } +} diff --git a/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0bccdc2 --- /dev/null +++ b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Svrnty.CQRS.Abstractions.Security; + +namespace Svrnty.CQRS.Altcha; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers as both an + /// and an + /// . The check is a no-op until + /// an + /// implementation is also registered (typically via + /// AddSvrntyAltchaGrpcVerifier(...) from + /// Svrnty.CQRS.Altcha.Grpc). + /// + /// + /// Idempotent for the concrete check; the multi-instance interface + /// registrations are added unconditionally, so callers should invoke + /// this exactly once per application startup. + /// + public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj b/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj new file mode 100644 index 0000000..18e690d --- /dev/null +++ b/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj @@ -0,0 +1,39 @@ + + + net10.0 + true + 14 + enable + enable + + Svrnty + David Lebee, Mathias Beaulieu-Duncan + icon.png + README.md + https://git.openharbor.io/svrnty/dotnet-cqrs + git + true + MIT + + portable + true + true + true + snupkg + + + + + + + + + + + + + + + + + diff --git a/Svrnty.CQRS.sln b/Svrnty.CQRS.sln index 7ea601d..9212eca 100644 --- a/Svrnty.CQRS.sln +++ b/Svrnty.CQRS.sln @@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstractions", "Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj", "{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -271,6 +273,18 @@ Global {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.Build.0 = Release|Any CPU {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = Release|Any CPU {753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.Build.0 = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.Build.0 = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.Build.0 = Debug|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.Build.0 = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.ActiveCfg = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.Build.0 = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.ActiveCfg = Release|Any CPU + {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE