dotnet-cqrs/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs
Mathias Beaulieu-Duncan 69e29d4f6d 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>
2026-05-12 16:24:02 -04:00

94 lines
3.8 KiB
C#

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;
}
}