Budget registration per address; embed difficulty; expiry queue; 404 on empty withdraw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0001c9d6d6
commit
e67257c750
11 changed files with 368 additions and 36 deletions
|
|
@ -34,9 +34,12 @@ server verifies the protection, the expiry and the hash, and spends the id once.
|
|||
script all pass the same way, at the cost of CPU time on the client.
|
||||
- Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser
|
||||
JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a
|
||||
proof of humanity.
|
||||
- Replay: the spent-id set is in memory and bounded; a restart forgets it, so a challenge can be replayed for at most its
|
||||
five-minute life. When the set is full the endpoint refuses rather than grows.
|
||||
proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with.
|
||||
- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge
|
||||
can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address
|
||||
cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget
|
||||
(`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker
|
||||
with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md.
|
||||
|
||||
Not built: the sign-up form. No registration page exists today; the endpoint is the contract and the only client is a
|
||||
script. The form (solving in a worker, a `<noscript>` message) is the cut after this one. Without JavaScript a person
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ public sealed class RegistrationOptions
|
|||
public const int MaxDifficulty = 30;
|
||||
|
||||
public RegistrationCaptchaOptions Captcha { get; init; } = new();
|
||||
|
||||
public RegistrationLimitsOptions Limits { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One budget on GET /auth/bifrost/challenge and POST /auth/bifrost/register together, per source address in one fixed window.</summary>
|
||||
public sealed class RegistrationLimitsOptions
|
||||
{
|
||||
public int PerAddressPermits { get; init; } = 10;
|
||||
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxTrackedKeys { get; init; } = 10_000;
|
||||
}
|
||||
|
||||
public sealed class RegistrationCaptchaOptions
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions
|
|||
return;
|
||||
}
|
||||
|
||||
if (limit.Spend(AddressKey(http.Connection.RemoteIpAddress)) is { } byAddress)
|
||||
if (limit.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is { } byAddress)
|
||||
{
|
||||
await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter);
|
||||
return;
|
||||
|
|
@ -86,29 +86,6 @@ public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions
|
|||
}
|
||||
}
|
||||
|
||||
// An IPv6 caller owns its /64, so rotating the low bits buys nothing.
|
||||
private static string AddressKey(IPAddress? address)
|
||||
{
|
||||
if (address is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.ToString();
|
||||
}
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes).ToString() + "/64";
|
||||
}
|
||||
|
||||
private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter)
|
||||
{
|
||||
http.Response.StatusCode = status;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ public sealed class RegistrationChallengeService(
|
|||
private const int MaxNonceLength = 20;
|
||||
|
||||
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
|
||||
private readonly Dictionary<string, DateTimeOffset> consumed = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> consumed = new(StringComparer.Ordinal);
|
||||
// Ordered by expiry: a challenge consumed late has an earlier expiry than one consumed before it, so a plain FIFO would strand it.
|
||||
private readonly PriorityQueue<string, DateTimeOffset> expiries = new();
|
||||
private readonly object gate = new();
|
||||
|
||||
private int Difficulty => options.Value.Captcha.Difficulty;
|
||||
|
|
@ -46,9 +48,10 @@ public sealed class RegistrationChallengeService(
|
|||
}
|
||||
|
||||
var expiresAt = now + Lifetime;
|
||||
var difficulty = Difficulty;
|
||||
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
|
||||
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}");
|
||||
return new RegistrationChallenge(challenge, Difficulty, expiresAt);
|
||||
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}");
|
||||
return new RegistrationChallenge(challenge, difficulty, expiresAt);
|
||||
}
|
||||
|
||||
public bool TryConsume(string? challenge, string? nonce)
|
||||
|
|
@ -59,16 +62,18 @@ public sealed class RegistrationChallengeService(
|
|||
}
|
||||
|
||||
string id;
|
||||
int difficulty;
|
||||
DateTimeOffset expiresAt;
|
||||
try
|
||||
{
|
||||
var payload = protector.Unprotect(challenge).Split('.');
|
||||
if (payload.Length != 2)
|
||||
if (payload.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
id = payload[0];
|
||||
difficulty = int.Parse(payload[2], System.Globalization.CultureInfo.InvariantCulture);
|
||||
expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception)
|
||||
|
|
@ -83,7 +88,7 @@ public sealed class RegistrationChallengeService(
|
|||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
|
||||
if (LeadingZeroBits(hash) < Difficulty)
|
||||
if (LeadingZeroBits(hash) < difficulty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -91,20 +96,21 @@ public sealed class RegistrationChallengeService(
|
|||
lock (gate)
|
||||
{
|
||||
Purge(now);
|
||||
if (consumed.Count >= MaxConsumed || consumed.ContainsKey(id))
|
||||
if (consumed.Count >= MaxConsumed || !consumed.Add(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
consumed[id] = expiresAt;
|
||||
expiries.Enqueue(id, expiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Purge(DateTimeOffset now)
|
||||
{
|
||||
foreach (var id in consumed.Where(x => x.Value <= now).Select(x => x.Key).ToList())
|
||||
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
|
||||
{
|
||||
expiries.Dequeue();
|
||||
consumed.Remove(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>
|
||||
/// The per-address permits on the registration endpoints (challenge and register share one budget), on the shared
|
||||
/// fixed-window budget. It exists so one address cannot fill the consumed-challenge set alone. It fails closed:
|
||||
/// anything it cannot decide is a 503, never a pass.
|
||||
/// </summary>
|
||||
public sealed class RegistrationRateLimit(IOptions<RegistrationOptions> options, TimeProvider clock, ILogger<RegistrationRateLimit> logger)
|
||||
{
|
||||
private readonly FixedWindowBudget addresses = new(
|
||||
options.Value.Limits.PerAddressPermits,
|
||||
options.Value.Limits.Window,
|
||||
options.Value.Limits.MaxTrackedKeys,
|
||||
clock);
|
||||
|
||||
/// <summary>Null when the request may proceed; otherwise the refusal to return.</summary>
|
||||
public IResult? Refuse(HttpContext http)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (addresses.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter);
|
||||
return Results.Text("Too many registration requests from this address.", statusCode: StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError("Registration rate limit could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
|
||||
http.Response.Headers.RetryAfter = "60";
|
||||
return Results.Text("Registration is unavailable; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RetryAfter(TimeSpan wait) =>
|
||||
Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
32
src/Bifrost.Web/Features/Shared/ClientAddress.cs
Normal file
32
src/Bifrost.Web/Features/Shared/ClientAddress.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Net;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public static class ClientAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// The partition key for a per-address budget: the connection's address, already corrected by the forwarded-headers
|
||||
/// handling that trusts only the local proxy. An IPv6 caller owns its /64, so rotating the low bits buys nothing.
|
||||
/// </summary>
|
||||
public static string BudgetKey(IPAddress? address)
|
||||
{
|
||||
if (address is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.ToString();
|
||||
}
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes).ToString() + "/64";
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,12 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}.");
|
||||
}
|
||||
|
||||
var limits = registrationOptions.Value.Limits;
|
||||
if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1)
|
||||
{
|
||||
failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive.");
|
||||
}
|
||||
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
Refuse(failures);
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOpt
|
|||
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
|
||||
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
|
||||
builder.Services.AddSingleton<RegistrationChallengeService>();
|
||||
builder.Services.AddSingleton<RegistrationRateLimit>();
|
||||
builder.Services.AddHostedService<SealedBallotCloser>();
|
||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
|
|
@ -390,8 +391,14 @@ app.MapPost("/auth/bifrost/register", async (
|
|||
HttpContext httpContext,
|
||||
BifrostIdentityService bifrostIdentityService,
|
||||
RegistrationChallengeService challenges,
|
||||
RegistrationRateLimit rateLimit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
// Consent first, then the proof of work; a refusal here creates nothing.
|
||||
if (request.AcceptedNoticeVersion != TrainingNotice.Version)
|
||||
{
|
||||
|
|
@ -432,8 +439,13 @@ app.MapPost("/auth/bifrost/register", async (
|
|||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges) =>
|
||||
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
|
||||
{
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
var challenge = challenges.Issue();
|
||||
return challenge is null
|
||||
|
|
@ -460,6 +472,11 @@ app.MapPost("/auth/bifrost/consent/withdraw", async (
|
|||
var receipts = await dbContext.ConsentReceipts
|
||||
.Where(x => x.UserAccountId == actor.UserAccount.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (receipts.Count == 0)
|
||||
{
|
||||
return Results.Text("No consent receipt is recorded for this account.", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
foreach (var receipt in receipts.Where(x => x.WithdrawnAtUtc is null))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -278,6 +278,30 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl
|
|||
Assert.DoesNotContain(db.ConsentReceipts, x => x.WithdrawnAtUtc != null && x.UserAccount!.NormalizedBifrostIdentity == identity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_for_an_account_with_no_receipt_is_404()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.UserAccounts.Add(new Bifrost.Web.Domain.UserAccount
|
||||
{
|
||||
BifrostIdentity = identity,
|
||||
NormalizedBifrostIdentity = identity,
|
||||
DisplayName = identity
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Bifrost-Identity", identity);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion()
|
||||
{
|
||||
|
|
|
|||
211
tests/Bifrost.Web.Tests/RegistrationLimitTests.cs
Normal file
211
tests/Bifrost.Web.Tests/RegistrationLimitTests.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The challenge service on its own: single use under contention, the bounded set, the purpose, and the difficulty it issued.
|
||||
public sealed class RegistrationChallengeServiceTests
|
||||
{
|
||||
private sealed class MutableOptions(int difficulty) : IOptions<RegistrationOptions>
|
||||
{
|
||||
public RegistrationOptions Value { get; set; } = With(difficulty);
|
||||
public static RegistrationOptions With(int d) => new() { Captcha = new RegistrationCaptchaOptions { Difficulty = d } };
|
||||
}
|
||||
|
||||
private static RegistrationChallengeService NewService(IDataProtectionProvider provider, IOptions<RegistrationOptions> options, TimeProvider clock) =>
|
||||
new(provider, options, clock);
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_consumes_of_one_challenge_yield_exactly_one_success()
|
||||
{
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), new ManualClock());
|
||||
for (var round = 0; round < 300; round++)
|
||||
{
|
||||
var challenge = service.Issue()!;
|
||||
var nonce = RegistrationKit.Solve(challenge.Challenge, challenge.Difficulty);
|
||||
using var start = new ManualResetEventSlim();
|
||||
|
||||
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(
|
||||
() => { start.Wait(); return service.TryConsume(challenge.Challenge, nonce); },
|
||||
TaskCreationOptions.LongRunning)).ToArray();
|
||||
start.Set();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(1, results.Count(x => x));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_full_consumed_set_refuses_to_issue_and_to_consume_until_entries_expire()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), clock);
|
||||
var pending = new List<(string, string)>();
|
||||
for (var i = 0; i < RegistrationChallengeService.MaxConsumed + 1; i++)
|
||||
{
|
||||
var c = service.Issue()!;
|
||||
pending.Add((c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
|
||||
}
|
||||
|
||||
foreach (var (c, n) in pending.Take(RegistrationChallengeService.MaxConsumed))
|
||||
{
|
||||
Assert.True(service.TryConsume(c, n));
|
||||
}
|
||||
|
||||
Assert.Null(service.Issue());
|
||||
Assert.False(service.TryConsume(pending[^1].Item1, pending[^1].Item2));
|
||||
|
||||
clock.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
Assert.NotNull(service.Issue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_token_protected_for_another_purpose_is_refused()
|
||||
{
|
||||
var provider = new EphemeralDataProtectionProvider();
|
||||
var clock = new ManualClock();
|
||||
var service = NewService(provider, new MutableOptions(1), clock);
|
||||
var expiry = (clock.GetUtcNow() + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
|
||||
var forged = provider.CreateProtector("Bifrost.Something.Else.v1").Protect($"{Guid.NewGuid():N}.{expiry}.1");
|
||||
var nonce = RegistrationKit.Solve(forged, 1);
|
||||
|
||||
Assert.False(service.TryConsume(forged, nonce));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_challenge_verifies_at_the_difficulty_it_was_issued_with()
|
||||
{
|
||||
var options = new MutableOptions(1);
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), options, new ManualClock());
|
||||
var easy = service.Issue()!;
|
||||
var easyNonce = RegistrationKit.Solve(easy.Challenge, 1);
|
||||
// A nonce that meets 1 bit but not 12: only the embedded difficulty lets it through after the option rises.
|
||||
var weak = Enumerable.Range(0, int.MaxValue).Select(i => i.ToString()).First(n =>
|
||||
{
|
||||
var bits = RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(easy.Challenge + ":" + n)));
|
||||
return bits >= 1 && bits < 12;
|
||||
});
|
||||
|
||||
options.Value = MutableOptions.With(12);
|
||||
|
||||
Assert.Equal(1, easy.Difficulty);
|
||||
Assert.True(service.TryConsume(easy.Challenge, weak));
|
||||
Assert.Equal(12, service.Issue()!.Difficulty);
|
||||
_ = easyNonce;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationBudgetFactory : TestWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Bifrost:Registration:Limits:PerAddressPermits"] = "3"
|
||||
}));
|
||||
builder.ConfigureServices(services => services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, PeerFilter>());
|
||||
}
|
||||
|
||||
private sealed class PeerFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
|
||||
{
|
||||
public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app =>
|
||||
{
|
||||
app.Use((context, nextMiddleware) =>
|
||||
{
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "198.51.100.1");
|
||||
return nextMiddleware();
|
||||
});
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
|
||||
{
|
||||
private static async Task<HttpResponseMessage> ChallengeAsync(HttpClient client, string peer)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/challenge");
|
||||
request.Headers.Add("X-Test-Peer", peer);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_request_past_the_address_budget_gets_429_and_another_address_is_unaffected()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, "203.0.113.5");
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var refused = await ChallengeAsync(client, "203.0.113.5");
|
||||
using var other = await ChallengeAsync(client, "203.0.113.6");
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
Assert.True(refused.Headers.RetryAfter is not null);
|
||||
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_and_register_spend_one_budget_and_a_refused_register_creates_nothing()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
const string peer = "203.0.113.7";
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, peer);
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
|
||||
{
|
||||
Content = JsonContent.Create(new { identity = $"budget-{Guid.NewGuid():N}", acceptedNoticeVersion = TrainingNotice.Version })
|
||||
};
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
request.Headers.Add("X-Test-Peer", peer);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
Assert.DoesNotContain(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts, x => x.NormalizedBifrostIdentity!.StartsWith("budget-"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFullSetTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_full_consumed_set_makes_the_challenge_endpoint_answer_503()
|
||||
{
|
||||
var service = factory.Services.GetRequiredService<RegistrationChallengeService>();
|
||||
for (var i = 0; i < RegistrationChallengeService.MaxConsumed; i++)
|
||||
{
|
||||
var c = service.Issue()!;
|
||||
Assert.True(service.TryConsume(c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var response = await client.GetAsync("/auth/bifrost/challenge");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
using var after = await client.GetAsync("/auth/bifrost/challenge");
|
||||
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["ConceptVotes:MintPermits"] = "100000",
|
||||
["MindIntake:Permits"] = "100000",
|
||||
["Bifrost:Registration:Captcha:Difficulty"] = "4",
|
||||
["Bifrost:Registration:Limits:PerAddressPermits"] = "100000",
|
||||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false",
|
||||
|
|
|
|||
Loading…
Reference in a new issue