Compare commits
9 Commits
main
...
feat/altch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a7a683b7 | ||
|
|
ede9548cba | ||
|
|
891894d136 | ||
|
|
4446288bb6 | ||
|
|
69e29d4f6d | ||
|
|
118d12a3db | ||
|
|
86d87424ab | ||
|
|
a05ebad7fc | ||
|
|
ee3ad866d9 |
@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared shape for command and query authorization-check contexts. Checks
|
||||||
|
/// receive the request type, the materialized (and validated) request instance,
|
||||||
|
/// a scoped <see cref="IServiceProvider"/>, and a free-form <see cref="Items"/>
|
||||||
|
/// dictionary that lets checks in the same pipeline pass signals to each other
|
||||||
|
/// (e.g. a future mobile-attestation check stamping "mobile_attested" for the
|
||||||
|
/// Altcha check to read).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required IServiceProvider Services { get; init; }
|
||||||
|
|
||||||
|
public IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CommandAuthorizationCheckContext : AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required Type CommandType { get; init; }
|
||||||
|
|
||||||
|
public required object Command { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class QueryAuthorizationCheckContext : AuthorizationCheckContext
|
||||||
|
{
|
||||||
|
public required Type QueryType { get; init; }
|
||||||
|
|
||||||
|
public required object Query { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-cutting authorization check that runs alongside (not in place of) the
|
||||||
|
/// consumer's <see cref="ICommandAuthorizationService"/>. Multiple
|
||||||
|
/// implementations may be registered; the framework resolves them as
|
||||||
|
/// <c>IEnumerable<ICommandAuthorizationCheck></c> and runs each in
|
||||||
|
/// registration order. AND semantics — any non-<see cref="AuthorizationResult.Allowed"/>
|
||||||
|
/// short-circuits the pipeline.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Use this seam for self-applying, attribute-driven checks shipped by
|
||||||
|
/// framework modules (proof-of-work, mobile attestation, rate-limit gates,
|
||||||
|
/// IP allow-lists). The check is responsible for inspecting
|
||||||
|
/// <see cref="CommandAuthorizationCheckContext.CommandType"/> attributes and
|
||||||
|
/// no-op'ing (return <see cref="AuthorizationResult.Allowed"/>) when it
|
||||||
|
/// doesn't apply.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ICommandAuthorizationCheck
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> CheckAsync(
|
||||||
|
CommandAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query-side counterpart to <see cref="ICommandAuthorizationCheck"/>. See
|
||||||
|
/// that interface's remarks for usage.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQueryAuthorizationCheck
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> CheckAsync(
|
||||||
|
QueryAuthorizationCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
27
Svrnty.CQRS.Altcha.Abstractions/AltchaAttribute.cs
Normal file
27
Svrnty.CQRS.Altcha.Abstractions/AltchaAttribute.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a command or query as requiring proof-of-work (or equivalent
|
||||||
|
/// anti-abuse evidence) before the framework will dispatch it to the handler.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The accompanying request type should implement <see cref="IHasAltchaSolution"/>
|
||||||
|
/// to carry the widget's solution payload. The framework's Altcha
|
||||||
|
/// authorization check (registered via <c>AddSvrntyAltcha()</c>) reads the
|
||||||
|
/// solution off the request and calls the configured <see cref="IAltchaVerifier"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class AltchaAttribute : Attribute
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> (default), a valid mobile-attestation token on the
|
||||||
|
/// request satisfies the requirement without needing a proof-of-work
|
||||||
|
/// solution. The Altcha check reads
|
||||||
|
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
|
||||||
|
/// — when stamped <c>true</c> by an earlier check (e.g. an Apple
|
||||||
|
/// App Attest / Play Integrity verifier), the PoW check is skipped.
|
||||||
|
/// Set to <c>false</c> on commands where PoW must always run regardless
|
||||||
|
/// of caller.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowMobileAttestationBypass { get; set; } = true;
|
||||||
|
}
|
||||||
28
Svrnty.CQRS.Altcha.Abstractions/AltchaChallenge.cs
Normal file
28
Svrnty.CQRS.Altcha.Abstractions/AltchaChallenge.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-issued Altcha challenge. Shape matches what the
|
||||||
|
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
|
||||||
|
/// expects from its <c>challengeurl</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaChallenge
|
||||||
|
{
|
||||||
|
/// <summary>Hashing algorithm name (e.g. <c>SHA-256</c>).</summary>
|
||||||
|
public required string Algorithm { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Hex-encoded hash the client must find a preimage for.</summary>
|
||||||
|
public required string Challenge { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Hex-encoded salt (embeds an expiry timestamp).</summary>
|
||||||
|
public required string Salt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HMAC-SHA256 of <c>algorithm|challenge|salt|maxnumber</c> using the
|
||||||
|
/// server's signing secret. Lets the verify step trust the issued
|
||||||
|
/// challenge without keeping per-challenge state.
|
||||||
|
/// </summary>
|
||||||
|
public required string Signature { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Upper bound of the PoW search space (equals the requested complexity).</summary>
|
||||||
|
public required uint MaxNumber { get; init; }
|
||||||
|
}
|
||||||
20
Svrnty.CQRS.Altcha.Abstractions/AltchaVerifyResult.cs
Normal file
20
Svrnty.CQRS.Altcha.Abstractions/AltchaVerifyResult.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of an Altcha solution verification.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaVerifyResult
|
||||||
|
{
|
||||||
|
public required bool Ok { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic only — not surfaced to end users. Suggested values:
|
||||||
|
/// <c>signature-invalid</c>, <c>expired</c>, <c>pow-incorrect</c>,
|
||||||
|
/// <c>replayed</c>, <c>redis-unreachable</c>, <c>malformed</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
|
||||||
|
public static AltchaVerifyResult Success { get; } = new() { Ok = true };
|
||||||
|
|
||||||
|
public static AltchaVerifyResult Fail(string reason) => new() { Ok = false, Reason = reason };
|
||||||
|
}
|
||||||
13
Svrnty.CQRS.Altcha.Abstractions/IAltchaChallengeProvider.cs
Normal file
13
Svrnty.CQRS.Altcha.Abstractions/IAltchaChallengeProvider.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mints an Altcha challenge for the widget to solve. The default
|
||||||
|
/// implementation in <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted
|
||||||
|
/// altcha service; the <c>Svrnty.CQRS.Altcha.MinimalApi</c> package exposes
|
||||||
|
/// <c>GET /api/altcha/challenge</c> that returns this DTO as the JSON
|
||||||
|
/// shape the widget expects.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAltchaChallengeProvider
|
||||||
|
{
|
||||||
|
Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
27
Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs
Normal file
27
Svrnty.CQRS.Altcha.Abstractions/IAltchaDifficultyAdvisor.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a per-request PoW complexity (search-space upper bound) for the
|
||||||
|
/// next Altcha challenge the server will mint. Implementations may consult
|
||||||
|
/// per-actor signals — repeat-offender counters, threat-intel headers,
|
||||||
|
/// reputation scores — to scale difficulty up for suspicious actors while
|
||||||
|
/// keeping the baseline cheap for everyone else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The framework ships a no-op <see cref="IAltchaDifficultyAdvisor"/> that
|
||||||
|
/// always returns <c>null</c>, meaning "use the upstream service's configured
|
||||||
|
/// default complexity." Applications opt into adaptive difficulty by
|
||||||
|
/// replacing the registration with their own implementation; consult request
|
||||||
|
/// context via <see cref="Microsoft.AspNetCore.Http.IHttpContextAccessor"/>
|
||||||
|
/// or scoped DI.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAltchaDifficultyAdvisor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the desired <c>maxNumber</c> (PoW search-space upper bound)
|
||||||
|
/// for the next challenge, or <c>null</c> to defer to the upstream
|
||||||
|
/// service default. The Altcha server clamps to its configured min/max,
|
||||||
|
/// so callers don't need to enforce bounds here.
|
||||||
|
/// </summary>
|
||||||
|
Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
18
Svrnty.CQRS.Altcha.Abstractions/IAltchaVerifier.cs
Normal file
18
Svrnty.CQRS.Altcha.Abstractions/IAltchaVerifier.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies an Altcha solution payload. The default implementation in
|
||||||
|
/// <c>Svrnty.CQRS.Altcha.Grpc</c> talks to a self-hosted altcha service
|
||||||
|
/// over gRPC; consumers may register their own implementation (e.g. an
|
||||||
|
/// in-process variant or a different transport) and the
|
||||||
|
/// <see cref="ICommandAuthorizationCheck"/>-based pipeline picks it up
|
||||||
|
/// automatically.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAltchaVerifier
|
||||||
|
{
|
||||||
|
/// <param name="payload">
|
||||||
|
/// Base64-encoded JSON payload produced by the Altcha widget — the same
|
||||||
|
/// string carried on <see cref="IHasAltchaSolution.AltchaSolution"/>.
|
||||||
|
/// </param>
|
||||||
|
Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
16
Svrnty.CQRS.Altcha.Abstractions/IHasAltchaSolution.cs
Normal file
16
Svrnty.CQRS.Altcha.Abstractions/IHasAltchaSolution.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implemented by command and query POCOs that carry an Altcha widget
|
||||||
|
/// solution. The framework's Altcha check reads <see cref="AltchaSolution"/>
|
||||||
|
/// off the materialized request, so the value travels naturally over HTTP
|
||||||
|
/// (JSON body field) and gRPC (proto field) without any extra plumbing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHasAltchaSolution
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base64-encoded JSON payload produced by the Altcha widget. Null /
|
||||||
|
/// empty causes the check to reject the request.
|
||||||
|
/// </summary>
|
||||||
|
string? AltchaSolution { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 3 placeholder — when a future module implements Apple App Attest /
|
||||||
|
/// Google Play Integrity verification, it stamps
|
||||||
|
/// <see cref="Svrnty.CQRS.Abstractions.Security.AuthorizationCheckContext.Items"/>[<c>"mobile_attested"</c>]
|
||||||
|
/// based on the verification result, and the Altcha check reads that flag
|
||||||
|
/// to short-circuit when <see cref="AltchaAttribute.AllowMobileAttestationBypass"/>
|
||||||
|
/// is true.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Intentionally left abstract and unwired in this phase. The interface
|
||||||
|
/// exists so Phase 3 can drop in an implementation without touching command
|
||||||
|
/// definitions or the Altcha check.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IMobileAttestationProvider
|
||||||
|
{
|
||||||
|
/// <param name="attestationToken">Platform-specific attestation token from the request.</param>
|
||||||
|
/// <returns><c>true</c> if attestation passes; <c>false</c> otherwise.</returns>
|
||||||
|
Task<bool> VerifyAsync(string attestationToken, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<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>
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
27
Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs
Normal file
27
Svrnty.CQRS.Altcha.Grpc/AltchaCallCredentials.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper that builds gRPC call metadata (an <c>Authorization</c> header)
|
||||||
|
/// from <see cref="AltchaGrpcOptions.TokenProvider"/>. Kept as a separate
|
||||||
|
/// shared helper so the verifier and challenge provider apply identical
|
||||||
|
/// rules.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AltchaCallCredentials
|
||||||
|
{
|
||||||
|
public static async Task<Metadata?> BuildMetadataAsync(AltchaGrpcOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.TokenProvider is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var token = await options.TokenProvider(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new Metadata
|
||||||
|
{
|
||||||
|
{ "Authorization", $"Bearer {token}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs
Normal file
69
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcChallengeProvider.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IAltchaChallengeProvider"/> backed by gRPC. Calls
|
||||||
|
/// <c>AltchaService.CreateChallenge</c> on the configured endpoint and
|
||||||
|
/// projects the response onto <see cref="AltchaChallenge"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaGrpcChallengeProvider : IAltchaChallengeProvider
|
||||||
|
{
|
||||||
|
private readonly AltchaService.AltchaServiceClient _client;
|
||||||
|
private readonly IOptions<AltchaGrpcOptions> _options;
|
||||||
|
private readonly IAltchaDifficultyAdvisor _advisor;
|
||||||
|
private readonly ILogger<AltchaGrpcChallengeProvider> _logger;
|
||||||
|
|
||||||
|
public AltchaGrpcChallengeProvider(
|
||||||
|
AltchaService.AltchaServiceClient client,
|
||||||
|
IOptions<AltchaGrpcOptions> options,
|
||||||
|
IAltchaDifficultyAdvisor advisor,
|
||||||
|
ILogger<AltchaGrpcChallengeProvider> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_options = options;
|
||||||
|
_advisor = advisor;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AltchaChallenge> CreateAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var opts = _options.Value;
|
||||||
|
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
|
||||||
|
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
|
||||||
|
|
||||||
|
var request = new CreateChallengeRequest();
|
||||||
|
var advisedComplexity = await _advisor.GetComplexityAsync(cancellationToken);
|
||||||
|
if (advisedComplexity is uint complexity)
|
||||||
|
{
|
||||||
|
request.Complexity = complexity;
|
||||||
|
_logger.LogDebug("Altcha advisor requested complexity {Complexity}", complexity);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _client.CreateChallengeAsync(
|
||||||
|
request,
|
||||||
|
headers: metadata,
|
||||||
|
deadline: deadline,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return new AltchaChallenge
|
||||||
|
{
|
||||||
|
Algorithm = response.Algorithm,
|
||||||
|
Challenge = response.ChallengeHash,
|
||||||
|
Salt = response.Salt,
|
||||||
|
Signature = response.Signature,
|
||||||
|
MaxNumber = response.Maxnumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Altcha create-challenge failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs
Normal file
31
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcOptions.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
namespace Svrnty.CQRS.Altcha.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for <see cref="AltchaGrpcVerifier"/> and
|
||||||
|
/// <see cref="AltchaGrpcChallengeProvider"/>. Bind from configuration
|
||||||
|
/// (e.g. <c>"Altcha"</c> section) or pass via the registration delegate.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaGrpcOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// gRPC endpoint of the altcha service. Typically the internal
|
||||||
|
/// docker / k8s address — e.g. <c>http://altcha:9090</c> or
|
||||||
|
/// <c>https://altcha.planb.svc.cluster.local:9090</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional per-call HMAC service-token provider. When set, the
|
||||||
|
/// returned string is sent as <c>Authorization: Bearer <token></c>
|
||||||
|
/// on every outbound gRPC call. Use this to integrate with whatever
|
||||||
|
/// service-auth scheme the rest of the deployment uses (e.g. plan-b's
|
||||||
|
/// <c>ServiceTokenIssuer.GetToken("altcha")</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Func<CancellationToken, Task<string>>? TokenProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-call timeout for both <c>CreateChallenge</c> and
|
||||||
|
/// <c>VerifyChallenge</c>. Defaults to 5s.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
}
|
||||||
66
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs
Normal file
66
Svrnty.CQRS.Altcha.Grpc/AltchaGrpcVerifier.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IAltchaVerifier"/> backed by gRPC. Calls
|
||||||
|
/// <c>AltchaService.VerifyChallenge</c> on the configured endpoint and
|
||||||
|
/// maps any failure (transport error, deadline, server-reported failure)
|
||||||
|
/// to <see cref="AltchaVerifyResult.Fail"/>. Verification failures are
|
||||||
|
/// safe defaults — callers see an <c>Unauthorized</c> outcome from the
|
||||||
|
/// auth check.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaGrpcVerifier : IAltchaVerifier
|
||||||
|
{
|
||||||
|
private readonly AltchaService.AltchaServiceClient _client;
|
||||||
|
private readonly IOptions<AltchaGrpcOptions> _options;
|
||||||
|
private readonly ILogger<AltchaGrpcVerifier> _logger;
|
||||||
|
|
||||||
|
public AltchaGrpcVerifier(
|
||||||
|
AltchaService.AltchaServiceClient client,
|
||||||
|
IOptions<AltchaGrpcOptions> options,
|
||||||
|
ILogger<AltchaGrpcVerifier> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var opts = _options.Value;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var metadata = await AltchaCallCredentials.BuildMetadataAsync(opts, cancellationToken);
|
||||||
|
var deadline = DateTime.UtcNow.Add(opts.CallTimeout);
|
||||||
|
|
||||||
|
var response = await _client.VerifyChallengeAsync(
|
||||||
|
new VerifyChallengeRequest { Payload = payload },
|
||||||
|
headers: metadata,
|
||||||
|
deadline: deadline,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return response.Ok
|
||||||
|
? AltchaVerifyResult.Success
|
||||||
|
: AltchaVerifyResult.Fail(string.IsNullOrEmpty(response.Reason) ? "invalid" : response.Reason);
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Altcha verify timed out against {Endpoint}.", opts.Endpoint);
|
||||||
|
return AltchaVerifyResult.Fail("verify-timeout");
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Altcha service unavailable at {Endpoint}.", opts.Endpoint);
|
||||||
|
return AltchaVerifyResult.Fail("service-unavailable");
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Altcha verify failed against {Endpoint}: {Status}", opts.Endpoint, ex.StatusCode);
|
||||||
|
return AltchaVerifyResult.Fail("rpc-error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto
Normal file
69
Svrnty.CQRS.Altcha.Grpc/Protos/altcha.proto
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package svrnty.cqrs.altcha.v1;
|
||||||
|
|
||||||
|
option go_package = "svrnty.cqrs.altcha.v1;altchapb";
|
||||||
|
option csharp_namespace = "Svrnty.CQRS.Altcha.Grpc";
|
||||||
|
|
||||||
|
// AltchaService is the wire contract between any backend that gates
|
||||||
|
// commands/queries with the [Altcha] attribute and a self-hosted Altcha
|
||||||
|
// implementation. The default .NET client lives in this package; a
|
||||||
|
// reference Go server lives in plan-b's projects/altcha (which vendors
|
||||||
|
// this proto). Keep this file the source of truth for both sides.
|
||||||
|
service AltchaService {
|
||||||
|
// Mint a fresh challenge for the widget to solve. Server-stateless:
|
||||||
|
// the response embeds an HMAC signature that VerifyChallenge re-checks.
|
||||||
|
rpc CreateChallenge(CreateChallengeRequest) returns (Challenge);
|
||||||
|
|
||||||
|
// Verify a widget-produced solution payload. The server checks
|
||||||
|
// signature + expiry + PoW correctness, then atomically claims the
|
||||||
|
// challenge in a replay-protection cache (e.g. Redis SETNX) so each
|
||||||
|
// solution is single-use across all server replicas.
|
||||||
|
rpc VerifyChallenge(VerifyChallengeRequest) returns (VerifyChallengeResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateChallengeRequest {
|
||||||
|
// Optional per-request complexity override (PoW search-space upper
|
||||||
|
// bound). Higher = slower client solve. Server clamps to its
|
||||||
|
// configured min/max. Omit to use the server default.
|
||||||
|
optional uint32 complexity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Challenge {
|
||||||
|
// Hashing algorithm — e.g. "SHA-256" (v2 default). Future versions may
|
||||||
|
// upgrade to PBKDF2 / Argon2; the field lets the widget pick the
|
||||||
|
// right solver.
|
||||||
|
string algorithm = 1;
|
||||||
|
|
||||||
|
// Hex-encoded hash the client must find a preimage for. Field is
|
||||||
|
// named "challenge_hash" rather than "challenge" to avoid a C#
|
||||||
|
// property/class name collision (the generated message class is
|
||||||
|
// also named Challenge); JSON projection for the widget remaps it.
|
||||||
|
string challenge_hash = 2;
|
||||||
|
|
||||||
|
// Hex-encoded random salt. Typically embeds the expiry timestamp so
|
||||||
|
// verifiers can re-derive the TTL without server state.
|
||||||
|
string salt = 3;
|
||||||
|
|
||||||
|
// HMAC-SHA256(secret, algorithm|challenge|salt|maxnumber). Lets
|
||||||
|
// VerifyChallenge confirm the challenge was issued by this server.
|
||||||
|
string signature = 4;
|
||||||
|
|
||||||
|
// PoW search-space upper bound. Equals the complexity used.
|
||||||
|
uint32 maxnumber = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyChallengeRequest {
|
||||||
|
// Base64-encoded JSON payload produced by the Altcha widget — the
|
||||||
|
// value carried over the wire on IHasAltchaSolution.AltchaSolution.
|
||||||
|
string payload = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyChallengeResponse {
|
||||||
|
bool ok = 1;
|
||||||
|
|
||||||
|
// Diagnostic only; not surfaced to end users. Suggested values:
|
||||||
|
// "signature-invalid", "expired", "pow-incorrect", "replayed",
|
||||||
|
// "redis-unreachable", "malformed".
|
||||||
|
string reason = 2;
|
||||||
|
}
|
||||||
64
Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs
Normal file
64
Svrnty.CQRS.Altcha.Grpc/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.Grpc;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the gRPC-backed <see cref="IAltchaVerifier"/> and
|
||||||
|
/// <see cref="IAltchaChallengeProvider"/>. Configure the endpoint
|
||||||
|
/// and optional service-auth token provider via the
|
||||||
|
/// <paramref name="configure"/> delegate.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// services.AddSvrntyAltcha();
|
||||||
|
/// services.AddSvrntyAltchaGrpcVerifier(opts =>
|
||||||
|
/// {
|
||||||
|
/// opts.Endpoint = "http://altcha:9090";
|
||||||
|
/// opts.TokenProvider = async ct => await tokenIssuer.GetTokenAsync("altcha", ct);
|
||||||
|
/// });
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<AltchaGrpcOptions> configure)
|
||||||
|
{
|
||||||
|
services.Configure(configure);
|
||||||
|
RegisterCore(services);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds <see cref="AltchaGrpcOptions"/> from a configuration section
|
||||||
|
/// (typically <c>"Altcha:Grpc"</c>) and registers the gRPC verifier
|
||||||
|
/// and challenge provider.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddSvrntyAltchaGrpcVerifier(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<AltchaGrpcOptions>(configuration);
|
||||||
|
RegisterCore(services);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterCore(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddGrpcClient<AltchaService.AltchaServiceClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<AltchaGrpcOptions>>().Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(opts.Endpoint))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Altcha gRPC endpoint not configured. Set AltchaGrpcOptions.Endpoint " +
|
||||||
|
"(e.g. http://altcha:9090).");
|
||||||
|
client.Address = new Uri(opts.Endpoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.TryAddSingleton<IAltchaVerifier, AltchaGrpcVerifier>();
|
||||||
|
services.TryAddSingleton<IAltchaChallengeProvider, AltchaGrpcChallengeProvider>();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj
Normal file
47
Svrnty.CQRS.Altcha.Grpc/Svrnty.CQRS.Altcha.Grpc.csproj
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsAotCompatible>false</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="Google.Protobuf" Version="3.30.2" />
|
||||||
|
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.71.0" />
|
||||||
|
<PackageReference Include="Grpc.Tools" Version="2.71.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Protos\altcha.proto" GrpcServices="Client" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
28
Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs
Normal file
28
Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.MinimalApi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON projection of <see cref="Svrnty.CQRS.Altcha.Abstractions.AltchaChallenge"/>
|
||||||
|
/// in the exact shape the
|
||||||
|
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget v3</a>
|
||||||
|
/// expects from a <c>challengeurl</c> response. Property names are
|
||||||
|
/// lowercased and <c>challenge</c> (no underscore) to match the widget.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AltchaChallengeDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("algorithm")]
|
||||||
|
public required string Algorithm { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("challenge")]
|
||||||
|
public required string Challenge { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("salt")]
|
||||||
|
public required string Salt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("signature")]
|
||||||
|
public required string Signature { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("maxnumber")]
|
||||||
|
public required uint MaxNumber { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha.MinimalApi;
|
||||||
|
|
||||||
|
public static class EndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps <c>GET {routePrefix}</c> (default <c>/api/altcha/challenge</c>)
|
||||||
|
/// returning a fresh challenge in the JSON shape the
|
||||||
|
/// <a href="https://altcha.org/docs/v2/widget-v3/">altcha widget</a>
|
||||||
|
/// consumes via its <c>challengeurl</c> attribute.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requires an <see cref="IAltchaChallengeProvider"/> to be registered
|
||||||
|
/// (typically by <c>AddSvrntyAltchaGrpcVerifier(...)</c>). The endpoint
|
||||||
|
/// allows anonymous access — the whole point is gating mutations from
|
||||||
|
/// unauthenticated callers, so the challenge endpoint must be reachable
|
||||||
|
/// without credentials.
|
||||||
|
/// </remarks>
|
||||||
|
public static IEndpointRouteBuilder MapSvrntyAltchaChallenge(
|
||||||
|
this IEndpointRouteBuilder endpoints,
|
||||||
|
string routePrefix = "/api/altcha/challenge")
|
||||||
|
{
|
||||||
|
endpoints.MapGet(routePrefix, async (
|
||||||
|
IAltchaChallengeProvider provider,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var challenge = await provider.CreateAsync(cancellationToken);
|
||||||
|
return Results.Ok(new AltchaChallengeDto
|
||||||
|
{
|
||||||
|
Algorithm = challenge.Algorithm,
|
||||||
|
Challenge = challenge.Challenge,
|
||||||
|
Salt = challenge.Salt,
|
||||||
|
Signature = challenge.Signature,
|
||||||
|
MaxNumber = challenge.MaxNumber
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("Altcha_Challenge_Get")
|
||||||
|
.WithTags("Altcha")
|
||||||
|
.Produces<AltchaChallengeDto>(200)
|
||||||
|
.Produces(503);
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<IsAotCompatible>false</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>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||||
|
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs
Normal file
15
Svrnty.CQRS.Altcha/NullAltchaDifficultyAdvisor.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op fallback registered by <c>AddSvrntyAltcha()</c>. Always returns
|
||||||
|
/// <c>null</c>, leaving complexity at the upstream Altcha service's
|
||||||
|
/// configured default. Applications that want adaptive difficulty
|
||||||
|
/// replace this registration with their own implementation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullAltchaDifficultyAdvisor : IAltchaDifficultyAdvisor
|
||||||
|
{
|
||||||
|
public Task<uint?> GetComplexityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult<uint?>(null);
|
||||||
|
}
|
||||||
36
Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
Normal file
36
Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Svrnty.CQRS.Abstractions.Security;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Altcha;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers <see cref="AltchaAuthorizationCheck"/> as both an
|
||||||
|
/// <see cref="ICommandAuthorizationCheck"/> and an
|
||||||
|
/// <see cref="IQueryAuthorizationCheck"/>, plus a no-op
|
||||||
|
/// <see cref="IAltchaDifficultyAdvisor"/> that defers to the upstream
|
||||||
|
/// Altcha service's configured default complexity. Applications opt
|
||||||
|
/// into adaptive difficulty by registering their own
|
||||||
|
/// <see cref="IAltchaDifficultyAdvisor"/> before or after this call —
|
||||||
|
/// the <c>TryAdd</c> registration here yields to any existing one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The check is a no-op until an
|
||||||
|
/// <see cref="IAltchaVerifier"/> implementation is also registered
|
||||||
|
/// (typically via <c>AddSvrntyAltchaGrpcVerifier(...)</c> from
|
||||||
|
/// <c>Svrnty.CQRS.Altcha.Grpc</c>). 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>());
|
||||||
|
services.TryAddSingleton<IAltchaDifficultyAdvisor, NullAltchaDifficultyAdvisor>();
|
||||||
|
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>
|
||||||
@ -858,7 +858,8 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
|
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
|
||||||
return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => System.Guid.Parse(x)).ToArray() ?? System.Array.Empty<System.Guid>()),";
|
return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => System.Guid.Parse(x)).ToArray() ?? System.Array.Empty<System.Guid>()),";
|
||||||
}
|
}
|
||||||
return $"{indent}{prop.Name} = {source}?.Select(x => System.Guid.Parse(x)).ToList(),";
|
// proto repeated fields are never null — drop ?. to avoid CS8601 on assignment to non-nullable target
|
||||||
|
return $"{indent}{prop.Name} = {source}.Select(x => System.Guid.Parse(x)).ToList(),";
|
||||||
}
|
}
|
||||||
else if (prop.IsValueTypeCollection)
|
else if (prop.IsValueTypeCollection)
|
||||||
{
|
{
|
||||||
@ -869,7 +870,8 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Primitive list: just ToList()
|
// Primitive list: just ToList()
|
||||||
return $"{indent}{prop.Name} = {source}?.ToList(),";
|
// proto repeated fields are never null — drop ?. to avoid CS8601 on assignment to non-nullable target
|
||||||
|
return $"{indent}{prop.Name} = {source}.ToList(),";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -884,11 +886,11 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
{
|
{
|
||||||
if (prop.IsNullable)
|
if (prop.IsNullable)
|
||||||
{
|
{
|
||||||
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
|
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return $"{indent}{prop.Name} = decimal.Parse({source}),";
|
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -969,7 +971,9 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
// For value type collections, the proto message has an Items field containing the repeated elements
|
// For value type collections, the proto message has an Items field containing the repeated elements
|
||||||
var itemsSource = prop.IsValueTypeCollection ? $"{source}?.Items" : source;
|
var itemsSource = prop.IsValueTypeCollection ? $"{source}?.Items" : source;
|
||||||
sb.AppendLine($"{indent}{prop.Name} = {itemsSource}?.Select(x => new {prop.ElementType}");
|
// Value-type wrapper messages can be null (?.Items needs ?.). Plain proto repeated is never null.
|
||||||
|
var selectAccess = prop.IsValueTypeCollection ? "?." : ".";
|
||||||
|
sb.AppendLine($"{indent}{prop.Name} = {itemsSource}{selectAccess}Select(x => new {prop.ElementType}");
|
||||||
sb.AppendLine($"{indent}{{");
|
sb.AppendLine($"{indent}{{");
|
||||||
|
|
||||||
foreach (var nestedProp in prop.ElementNestedProperties!)
|
foreach (var nestedProp in prop.ElementNestedProperties!)
|
||||||
@ -1031,11 +1035,11 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
{
|
{
|
||||||
if (prop.IsNullable)
|
if (prop.IsNullable)
|
||||||
{
|
{
|
||||||
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
|
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return $"{indent}{prop.Name} = decimal.Parse({source}),";
|
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1078,7 +1082,8 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
|
var constructorType = prop.FullyQualifiedType.TrimEnd('?');
|
||||||
return $"{indent}{prop.Name} = new {constructorType}({source}?.ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),";
|
return $"{indent}{prop.Name} = new {constructorType}({source}?.ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),";
|
||||||
}
|
}
|
||||||
return $"{indent}{prop.Name} = {source}?.ToList(),";
|
// proto repeated fields are never null — drop ?. to avoid CS8601
|
||||||
|
return $"{indent}{prop.Name} = {source}.ToList(),";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle complex types
|
// Handle complex types
|
||||||
@ -2371,6 +2376,26 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(" }");
|
sb.AppendLine(" }");
|
||||||
sb.AppendLine(" }");
|
sb.AppendLine(" }");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see ICommandAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var commandChecks = serviceProvider.GetServices<ICommandAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (commandChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new CommandAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" CommandType = typeof({command.FullyQualifiedName}),");
|
||||||
|
sb.AppendLine(" Command = command,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in commandChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
|
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
|
||||||
|
|
||||||
if (command.HasResult)
|
if (command.HasResult)
|
||||||
@ -2488,6 +2513,27 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(assignment);
|
sb.AppendLine(assignment);
|
||||||
}
|
}
|
||||||
sb.AppendLine(" };");
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (queryChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" QueryType = typeof({query.FullyQualifiedName}),");
|
||||||
|
sb.AppendLine(" Query = query,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in queryChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
|
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
|
||||||
|
|
||||||
// Generate response with mapping if complex type
|
// Generate response with mapping if complex type
|
||||||
@ -2823,6 +2869,26 @@ public class GrpcGenerator : IIncrementalGenerator
|
|||||||
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
|
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
|
||||||
sb.AppendLine(" };");
|
sb.AppendLine(" };");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" // Authorization checks (cross-cutting; see IQueryAuthorizationCheck)");
|
||||||
|
sb.AppendLine(" var queryChecks = serviceProvider.GetServices<IQueryAuthorizationCheck>();");
|
||||||
|
sb.AppendLine(" if (queryChecks != null)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkContext = new QueryAuthorizationCheckContext");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine($" QueryType = typeof({dynamicQuery.QueryInterfaceName}),");
|
||||||
|
sb.AppendLine(" Query = query,");
|
||||||
|
sb.AppendLine(" Services = serviceProvider");
|
||||||
|
sb.AppendLine(" };");
|
||||||
|
sb.AppendLine(" foreach (var check in queryChecks)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var checkResult = await check.CheckAsync(checkContext, context.CancellationToken);");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Unauthorized)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.Unauthenticated, \"Unauthorized\"));");
|
||||||
|
sb.AppendLine(" if (checkResult == AuthorizationResult.Forbidden)");
|
||||||
|
sb.AppendLine(" throw new RpcException(new global::Grpc.Core.Status(global::Grpc.Core.StatusCode.PermissionDenied, \"Forbidden\"));");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
// Get the handler and execute
|
// Get the handler and execute
|
||||||
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
|
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@ -16,6 +17,64 @@ namespace Svrnty.CQRS.MinimalApi;
|
|||||||
|
|
||||||
public static class EndpointRouteBuilderExtensions
|
public static class EndpointRouteBuilderExtensions
|
||||||
{
|
{
|
||||||
|
private static async Task<IResult?> RunCommandChecksAsync(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
Type commandType,
|
||||||
|
object command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var checks = serviceProvider.GetServices<ICommandAuthorizationCheck>().ToList();
|
||||||
|
if (checks.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var context = new CommandAuthorizationCheckContext
|
||||||
|
{
|
||||||
|
CommandType = commandType,
|
||||||
|
Command = command,
|
||||||
|
Services = serviceProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var check in checks)
|
||||||
|
{
|
||||||
|
var result = await check.CheckAsync(context, cancellationToken);
|
||||||
|
if (result == AuthorizationResult.Forbidden)
|
||||||
|
return Results.StatusCode(403);
|
||||||
|
if (result == AuthorizationResult.Unauthorized)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult?> RunQueryChecksAsync(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
Type queryType,
|
||||||
|
object query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var checks = serviceProvider.GetServices<IQueryAuthorizationCheck>().ToList();
|
||||||
|
if (checks.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var context = new QueryAuthorizationCheckContext
|
||||||
|
{
|
||||||
|
QueryType = queryType,
|
||||||
|
Query = query,
|
||||||
|
Services = serviceProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var check in checks)
|
||||||
|
{
|
||||||
|
var result = await check.CheckAsync(context, cancellationToken);
|
||||||
|
if (result == AuthorizationResult.Forbidden)
|
||||||
|
return Results.StatusCode(403);
|
||||||
|
if (result == AuthorizationResult.Unauthorized)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
|
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
|
||||||
{
|
{
|
||||||
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
||||||
@ -63,6 +122,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
||||||
return Results.BadRequest("Invalid query payload");
|
return Results.BadRequest("Invalid query payload");
|
||||||
|
|
||||||
|
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
|
||||||
|
if (checkResult != null)
|
||||||
|
return checkResult;
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -128,6 +191,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var checkResult = await RunQueryChecksAsync(serviceProvider, queryMeta.QueryType, query, cancellationToken);
|
||||||
|
if (checkResult != null)
|
||||||
|
return checkResult;
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -198,6 +265,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
return Results.BadRequest("Invalid command payload");
|
||||||
|
|
||||||
|
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
|
||||||
|
if (checkResult != null)
|
||||||
|
return checkResult;
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
@ -240,6 +311,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
return Results.BadRequest("Invalid command payload");
|
||||||
|
|
||||||
|
var checkResult = await RunCommandChecksAsync(serviceProvider, commandMeta.CommandType, command, cancellationToken);
|
||||||
|
if (checkResult != null)
|
||||||
|
return checkResult;
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
var handleMethod = handlerType.GetMethod("HandleAsync");
|
var handleMethod = handlerType.GetMethod("HandleAsync");
|
||||||
if (handleMethod == null)
|
if (handleMethod == null)
|
||||||
|
|||||||
@ -43,6 +43,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
|
||||||
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}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Grpc", "Svrnty.CQRS.Altcha.Grpc\Svrnty.CQRS.Altcha.Grpc.csproj", "{628DE10C-FCDB-418B-8341-FA246BBCF70E}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.MinimalApi", "Svrnty.CQRS.Altcha.MinimalApi\Svrnty.CQRS.Altcha.MinimalApi.csproj", "{26B24C13-FA06-4611-A371-2B640B8066F2}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -257,6 +265,54 @@ Global
|
|||||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
|
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
|
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
|
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.ActiveCfg = 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.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
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
using Svrnty.CQRS;
|
using Svrnty.CQRS;
|
||||||
using Svrnty.CQRS.Abstractions;
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
using Svrnty.CQRS.Altcha;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
using Svrnty.CQRS.DynamicQuery;
|
using Svrnty.CQRS.DynamicQuery;
|
||||||
using Svrnty.CQRS.FluentValidation;
|
using Svrnty.CQRS.FluentValidation;
|
||||||
using Svrnty.CQRS.Grpc;
|
using Svrnty.CQRS.Grpc;
|
||||||
@ -27,8 +29,14 @@ builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
|||||||
// Register commands and queries with validators
|
// Register commands and queries with validators
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
|
builder.Services.AddCommand<ProtectedActionCommand, string, ProtectedActionCommandHandler>();
|
||||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
|
|
||||||
|
// Wire the Altcha check + a stub verifier so [Altcha] commands run
|
||||||
|
// through the ICommandAuthorizationCheck pipeline.
|
||||||
|
builder.Services.AddSvrntyAltcha();
|
||||||
|
builder.Services.AddSingleton<IAltchaVerifier, StubAltchaVerifier>();
|
||||||
|
|
||||||
// Configure CQRS with fluent API
|
// Configure CQRS with fluent API
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
{
|
{
|
||||||
|
|||||||
37
Svrnty.Sample/ProtectedActionCommand.cs
Normal file
37
Svrnty.Sample/ProtectedActionCommand.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
using Svrnty.CQRS.Altcha.Abstractions;
|
||||||
|
|
||||||
|
namespace Svrnty.Sample;
|
||||||
|
|
||||||
|
// Exercises the ICommandAuthorizationCheck seam at runtime.
|
||||||
|
// The command is decorated with [Altcha] and carries the solution
|
||||||
|
// through IHasAltchaSolution. With Svrnty.CQRS.Altcha + a registered
|
||||||
|
// IAltchaVerifier, the framework's check pipeline reads the field,
|
||||||
|
// calls the verifier, and short-circuits on failure.
|
||||||
|
[Altcha]
|
||||||
|
public sealed class ProtectedActionCommand : IHasAltchaSolution
|
||||||
|
{
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string? AltchaSolution { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProtectedActionCommandHandler : ICommandHandler<ProtectedActionCommand, string>
|
||||||
|
{
|
||||||
|
public Task<string> HandleAsync(ProtectedActionCommand command, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult($"executed:{command.Action}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub verifier that doesn't talk to an external altcha service —
|
||||||
|
// enough to exercise the check pipeline in isolation. Treats the
|
||||||
|
// literal string "valid-solution" as a passing PoW solution.
|
||||||
|
public sealed class StubAltchaVerifier : IAltchaVerifier
|
||||||
|
{
|
||||||
|
public Task<AltchaVerifyResult> VerifyAsync(string payload, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (payload == "valid-solution")
|
||||||
|
return Task.FromResult(AltchaVerifyResult.Success);
|
||||||
|
return Task.FromResult(AltchaVerifyResult.Fail("stub-rejected"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,9 @@ service CommandService {
|
|||||||
// AddUserCommand operation
|
// AddUserCommand operation
|
||||||
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
|
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
|
||||||
|
|
||||||
|
// ProtectedActionCommand operation
|
||||||
|
rpc ProtectedAction (ProtectedActionCommandRequest) returns (ProtectedActionCommandResponse);
|
||||||
|
|
||||||
// RemoveUserCommand operation
|
// RemoveUserCommand operation
|
||||||
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
|
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
|
||||||
|
|
||||||
@ -40,6 +43,17 @@ message AddUserCommandResponse {
|
|||||||
int32 result = 1;
|
int32 result = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request message for ProtectedActionCommand
|
||||||
|
message ProtectedActionCommandRequest {
|
||||||
|
string action = 1;
|
||||||
|
string altcha_solution = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response message for ProtectedActionCommand
|
||||||
|
message ProtectedActionCommandResponse {
|
||||||
|
string result = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Request message for RemoveUserCommand
|
// Request message for RemoveUserCommand
|
||||||
message RemoveUserCommandRequest {
|
message RemoveUserCommandRequest {
|
||||||
int32 user_id = 1;
|
int32 user_id = 1;
|
||||||
|
|||||||
@ -33,6 +33,8 @@
|
|||||||
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Import the proto generation targets for testing (in production this would come from the NuGet package) -->
|
<!-- Import the proto generation targets for testing (in production this would come from the NuGet package) -->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user