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>
94 lines
3.8 KiB
C#
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;
|
|
}
|
|
}
|