feat(altcha): add Svrnty.CQRS.Altcha core check + DI
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) <noreply@anthropic.com>
This commit is contained in:
parent
118d12a3db
commit
69e29d4f6d
93
Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs
Normal file
93
Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single check that powers the <see cref="AltchaAttribute"/>. Plays both
|
||||||
|
/// <see cref="ICommandAuthorizationCheck"/> and <see cref="IQueryAuthorizationCheck"/>
|
||||||
|
/// roles so a request payload of either kind is gated identically.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Self-applying: the check no-ops (returns <see cref="AuthorizationResult.Allowed"/>)
|
||||||
|
/// for any request whose type isn't decorated with <see cref="AltchaAttribute"/>.
|
||||||
|
/// Resolves <see cref="IAltchaVerifier"/> from the request scope per call so the
|
||||||
|
/// check is agnostic to the verifier's lifetime.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AltchaAuthorizationCheck : ICommandAuthorizationCheck, IQueryAuthorizationCheck
|
||||||
|
{
|
||||||
|
internal const string ReasonItemKey = "altcha_reason";
|
||||||
|
internal const string MobileAttestedItemKey = "mobile_attested";
|
||||||
|
|
||||||
|
private readonly ILogger<AltchaAuthorizationCheck> _logger;
|
||||||
|
|
||||||
|
public AltchaAuthorizationCheck(ILogger<AltchaAuthorizationCheck> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthorizationResult> CheckAsync(
|
||||||
|
CommandAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CheckCoreAsync(context, context.CommandType, context.Command, cancellationToken);
|
||||||
|
|
||||||
|
public Task<AuthorizationResult> CheckAsync(
|
||||||
|
QueryAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CheckCoreAsync(context, context.QueryType, context.Query, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<AuthorizationResult> CheckCoreAsync(
|
||||||
|
AuthorizationCheckContext context,
|
||||||
|
Type subjectType,
|
||||||
|
object subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var altchaAttr = subjectType.GetCustomAttribute<AltchaAttribute>(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<IAltchaVerifier>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
Normal file
30
Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
Normal file
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="AltchaAuthorizationCheck"/> as both an
|
||||||
|
/// <see cref="ICommandAuthorizationCheck"/> and an
|
||||||
|
/// <see cref="IQueryAuthorizationCheck"/>. The check is a no-op until
|
||||||
|
/// an <see cref="Svrnty.CQRS.Altcha.Abstractions.IAltchaVerifier"/>
|
||||||
|
/// implementation is also registered (typically via
|
||||||
|
/// <c>AddSvrntyAltchaGrpcVerifier(...)</c> from
|
||||||
|
/// <c>Svrnty.CQRS.Altcha.Grpc</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent for the concrete check; the multi-instance interface
|
||||||
|
/// registrations are added unconditionally, so callers should invoke
|
||||||
|
/// this exactly once per application startup.
|
||||||
|
/// </remarks>
|
||||||
|
public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.TryAddSingleton<AltchaAuthorizationCheck>();
|
||||||
|
services.AddSingleton<ICommandAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
|
||||||
|
services.AddSingleton<IQueryAuthorizationCheck>(sp => sp.GetRequiredService<AltchaAuthorizationCheck>());
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj
Normal file
39
Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
<LangVersion>14</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|
||||||
|
<Company>Svrnty</Company>
|
||||||
|
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
|
||||||
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
|
||||||
|
<DebugType>portable</DebugType>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<IncludeSource>true</IncludeSource>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||||
|
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ
|
|||||||
EndProject
|
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}"
|
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
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user