Registration gates: proof-of-work CAPTCHA and training-notice consent receipt (#54)
This commit is contained in:
parent
fc8242f655
commit
33be966b7d
28 changed files with 1253 additions and 56 deletions
|
|
@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
## Local Dev Notes
|
||||
|
||||
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
||||
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
|
||||
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing:
|
|||
|
||||
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
|
||||
|
||||
The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
|
||||
The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
|
|
|||
86
docs/registration-gates.md
Normal file
86
docs/registration-gates.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Public registration gates
|
||||
|
||||
Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification,
|
||||
a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote.
|
||||
This page scopes the three that did not exist, says where each lives, and records what is built.
|
||||
|
||||
## What exists
|
||||
|
||||
| Gate | Where | State |
|
||||
| --- | --- | --- |
|
||||
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
|
||||
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
|
||||
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
|
||||
| Suspension | `MembershipSuspensionService` | Merged. |
|
||||
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
|
||||
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
|
||||
| Email verification | nowhere | Scoped below, not built. |
|
||||
|
||||
The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an
|
||||
`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no
|
||||
registration form.
|
||||
|
||||
Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and
|
||||
expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that
|
||||
`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The
|
||||
server verifies the protection, the expiry and the hash, and spends the id once.
|
||||
|
||||
- Self-hosted: no third-party script, request or cookie; nothing to track.
|
||||
- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a
|
||||
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. 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
|
||||
cannot solve the challenge, so the accessible fallback for them is owed with the form: a documented solver is not one. Do
|
||||
not call the CAPTCHA gate complete for people until that fallback exists.
|
||||
|
||||
## Consent receipt
|
||||
|
||||
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
|
||||
decision (`admission-is-bifrost-membership`).
|
||||
|
||||
- Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's
|
||||
`answer-collector-consent.md`) and `TrainingNotice.Text`. Served at `GET /auth/bifrost/notice`. The text in the repo is
|
||||
draft wording. Eureka owns the final wording in gamecult-ops; changing the text is a version bump, never an edit in
|
||||
place. The text states that no Discord material is used.
|
||||
- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`), unique per account and version,
|
||||
written in the same save as the account. Registration without `acceptedNoticeVersion` equal to the current version
|
||||
creates nothing. A new notice version needs a new acceptance; there is no path yet that asks for one.
|
||||
- Withdrawal: `POST /auth/bifrost/consent/withdraw`, signed in, sets `WithdrawnAtUtc` once on the account's receipts.
|
||||
Withdrawal stops future use and cannot recall past use; the notice says so. The row is one-way and never deleted.
|
||||
- Consumer: a training export must include an account's data only while a receipt for the notice version in force
|
||||
exists with `WithdrawnAtUtc` null. No export exists in Bifrost; this page is the contract it will be built against.
|
||||
|
||||
Scope limits: accounts that arrive through Heimdall providers (Discord, Patreon, Twitch, GitHub) are created in
|
||||
`HeimdallAuthService.UpsertUserAccountAsync` and get no receipt. They reach chat the same way. Closing that needs a
|
||||
consent step on the completion page and is the next cut after the form. Existing accounts are not re-asked.
|
||||
|
||||
## Email verification
|
||||
|
||||
Not built. It cannot be built in Bifrost alone:
|
||||
|
||||
- There is no mail transport anywhere in Bifrost, Heimdall or the chat stack. A sender (SMTP relay or provider, SPF/DKIM
|
||||
for the sending domain) is ops infrastructure, and Bifrost's side is an `IEmailSender` port with that relay's settings
|
||||
as secret files.
|
||||
- A native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). Verifying an
|
||||
address proves nothing unless the address is also how the person returns. That makes email the credential: a
|
||||
single-use sign-in link, and the choice of whether Bifrost keeps the address (needed to send it) or only a hash (cannot
|
||||
send). The issuer doc promises no email leaves Bifrost; keeping one inside it is a privacy decision for the operator.
|
||||
- Shape once ruled: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a
|
||||
single-use token; no account exists yet. Step two redeems the token and creates the account and receipt together. An
|
||||
unverified account then cannot exist, so it cannot chat.
|
||||
|
||||
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts. Owed: a mail relay from ops, and the
|
||||
ruling on keeping the address.
|
||||
31
src/Bifrost.Web/Configuration/RegistrationOptions.cs
Normal file
31
src/Bifrost.Web/Configuration/RegistrationOptions.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
/// <summary>Gates on the anonymous native registration (section <c>Bifrost:Registration</c>).</summary>
|
||||
public sealed class RegistrationOptions
|
||||
{
|
||||
public const string SectionName = "Bifrost:Registration";
|
||||
|
||||
public const int MinDifficulty = 1;
|
||||
|
||||
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
|
||||
{
|
||||
/// <summary>Leading zero bits the SHA-256 of <c>challenge:nonce</c> must have. 18 is about 262k hashes, a second or two in a browser.</summary>
|
||||
public int Difficulty { get; init; } = 18;
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
|
||||
|
||||
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
||||
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
||||
|
||||
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
||||
|
|
@ -322,6 +323,15 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ConsentReceipt>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique();
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindKey>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddMindKeys: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111090000_AddConsentReceipts")]
|
||||
public partial class AddConsentReceipts : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsentReceipts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
NoticeVersion = table.Column<int>(type: "integer", nullable: false),
|
||||
AcceptedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
WithdrawnAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsentReceipts", x => x.Id);
|
||||
table.ForeignKey("FK_ConsentReceipts_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts", columns: new[] { "UserAccountId", "NoticeVersion" }, unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "ConsentReceipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1440,6 +1440,32 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("VolunteerClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("AcceptedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("NoticeVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("WithdrawnAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserAccountId", "NoticeVersion")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ConsentReceipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -2142,6 +2168,17 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("WorkItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,25 @@ public sealed class AuditEvent
|
|||
public UserAccount? ActorUserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets
|
||||
/// <see cref="WithdrawnAtUtc"/>; the row is never deleted, so what was agreed and when stays answerable.
|
||||
/// </summary>
|
||||
public sealed class ConsentReceipt
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public int NoticeVersion { get; set; }
|
||||
|
||||
public DateTimeOffset AcceptedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? WithdrawnAtUtc { get; set; }
|
||||
|
||||
public UserAccount? UserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
||||
public sealed class MindKey
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public sealed partial class BifrostIdentityService(
|
|||
public async Task<UserAccount> RegisterNativeAsync(
|
||||
string identity,
|
||||
string? displayName,
|
||||
int noticeVersion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedIdentity = NormalizeIdentity(identity);
|
||||
|
|
@ -57,7 +58,14 @@ public sealed partial class BifrostIdentityService(
|
|||
}
|
||||
};
|
||||
|
||||
// One SaveChanges: no account exists without the receipt of the notice it accepted.
|
||||
dbContext.UserAccounts.Add(userAccount);
|
||||
dbContext.ConsentReceipts.Add(new ConsentReceipt
|
||||
{
|
||||
UserAccount = userAccount,
|
||||
NoticeVersion = noticeVersion,
|
||||
AcceptedAtUtc = now
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return userAccount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
public sealed record RegistrationChallenge(string Challenge, int Difficulty, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted proof-of-work challenge for the anonymous registration. A challenge is a data-protected
|
||||
/// (random id, expiry) payload, so the server keeps nothing per issue; only consumed ids are remembered, in a bounded
|
||||
/// set whose entries are dropped at expiry. A full set refuses both issuing and consuming rather than grow.
|
||||
/// </summary>
|
||||
public sealed class RegistrationChallengeService(
|
||||
IDataProtectionProvider dataProtection,
|
||||
IOptions<RegistrationOptions> options,
|
||||
TimeProvider clock)
|
||||
{
|
||||
public const string Purpose = "Bifrost.Registration.Challenge.v1";
|
||||
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
public const int MaxConsumed = 10_000;
|
||||
|
||||
private const int MaxNonceLength = 20;
|
||||
|
||||
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
|
||||
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;
|
||||
|
||||
/// <summary>Null when the consumed set is full: refuse rather than grow.</summary>
|
||||
public RegistrationChallenge? Issue()
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
lock (gate)
|
||||
{
|
||||
Purge(now);
|
||||
if (consumed.Count >= MaxConsumed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt = now + Lifetime;
|
||||
var difficulty = Difficulty;
|
||||
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
|
||||
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}");
|
||||
return new RegistrationChallenge(challenge, difficulty, expiresAt);
|
||||
}
|
||||
|
||||
public bool TryConsume(string? challenge, string? nonce)
|
||||
{
|
||||
if (string.IsNullOrEmpty(challenge) || !IsNonce(nonce))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id;
|
||||
int difficulty;
|
||||
DateTimeOffset expiresAt;
|
||||
try
|
||||
{
|
||||
var payload = protector.Unprotect(challenge).Split('.');
|
||||
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)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
if (now >= expiresAt)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
|
||||
if (LeadingZeroBits(hash) < difficulty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
Purge(now);
|
||||
if (consumed.Count >= MaxConsumed || !consumed.Add(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
expiries.Enqueue(id, expiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Purge(DateTimeOffset now)
|
||||
{
|
||||
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
|
||||
{
|
||||
expiries.Dequeue();
|
||||
consumed.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNonce(string? nonce) =>
|
||||
nonce is { Length: >= 1 and <= MaxNonceLength } && nonce.All(c => c is >= '0' and <= '9');
|
||||
|
||||
public static int LeadingZeroBits(ReadOnlySpan<byte> hash)
|
||||
{
|
||||
var bits = 0;
|
||||
foreach (var b in hash)
|
||||
{
|
||||
if (b == 0)
|
||||
{
|
||||
bits += 8;
|
||||
continue;
|
||||
}
|
||||
|
||||
return bits + System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
|
||||
}
|
||||
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
17
src/Bifrost.Web/Features/Registration/TrainingNotice.cs
Normal file
17
src/Bifrost.Web/Features/Registration/TrainingNotice.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>
|
||||
/// The training notice a registrant accepts. DRAFT wording: Eureka replaces <see cref="Text"/>. Changing the wording
|
||||
/// in substance means a new <see cref="Version"/> (a positive integer, no leading zeros), because a receipt records the
|
||||
/// version the person accepted.
|
||||
/// </summary>
|
||||
public static class TrainingNotice
|
||||
{
|
||||
public const int Version = 1;
|
||||
|
||||
public const string Text =
|
||||
"Chat messages and activity on GameCult chat may be used to train and evaluate AI models. " +
|
||||
"No Discord material is used. " +
|
||||
"Your consent is voluntary. It is recorded with this notice version and the time you give it, " +
|
||||
"and you can withdraw it at any time. Withdrawing stops future use; it cannot recall use that already happened.";
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ public sealed class StartupConfigurationValidator(
|
|||
IOptions<StripeOptions> stripeOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<ConceptVoteOptions> conceptVoteOptions,
|
||||
IOptions<DeskOptions> deskOptions)
|
||||
IOptions<DeskOptions> deskOptions,
|
||||
IOptions<RegistrationOptions> registrationOptions)
|
||||
{
|
||||
public void Validate()
|
||||
{
|
||||
|
|
@ -48,6 +49,18 @@ public sealed class StartupConfigurationValidator(
|
|||
|
||||
ValidateDesk(deskOptions.Value, failures);
|
||||
|
||||
var difficulty = registrationOptions.Value.Captcha.Difficulty;
|
||||
if (difficulty < RegistrationOptions.MinDifficulty || difficulty > RegistrationOptions.MaxDifficulty)
|
||||
{
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ using Bifrost.Web.Features.Membership;
|
|||
using Bifrost.Web.Features.Motions;
|
||||
using Bifrost.Web.Features.Oidc;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
|
@ -131,6 +132,9 @@ builder.Services.AddScoped<SealedBallotService>();
|
|||
builder.Services.AddScoped<MindIntakeService>();
|
||||
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
|
||||
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>();
|
||||
|
|
@ -386,13 +390,34 @@ app.MapPost("/auth/bifrost/register", async (
|
|||
NativeBifrostRegistrationRequest request,
|
||||
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)
|
||||
{
|
||||
return Results.Text(
|
||||
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (!challenges.TryConsume(request.Challenge, request.Nonce))
|
||||
{
|
||||
return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
|
||||
request.Identity,
|
||||
request.DisplayName,
|
||||
TrainingNotice.Version,
|
||||
cancellationToken);
|
||||
await httpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
|
|
@ -414,6 +439,56 @@ app.MapPost("/auth/bifrost/register", async (
|
|||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
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
|
||||
? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable)
|
||||
: JsonResult(challenge, appJsonSerializerOptions);
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/bifrost/notice", () =>
|
||||
JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous();
|
||||
|
||||
// Withdrawal stops future use; the receipt row stays. Idempotent: an already-withdrawn receipt keeps its first timestamp.
|
||||
app.MapPost("/auth/bifrost/consent/withdraw", async (
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
BifrostDbContext dbContext,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
receipt.WithdrawnAtUtc = now;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return JsonResult(
|
||||
new { withdrawnAtUtc = receipts.Min(x => x.WithdrawnAtUtc) },
|
||||
appJsonSerializerOptions);
|
||||
});
|
||||
|
||||
app.MapGet("/auth/heimdall/{provider}", async (
|
||||
string provider,
|
||||
HttpContext httpContext,
|
||||
|
|
@ -792,7 +867,10 @@ public partial class Program;
|
|||
public sealed record NativeBifrostRegistrationRequest(
|
||||
string Identity,
|
||||
string? DisplayName,
|
||||
string? ReturnUrl);
|
||||
string? ReturnUrl,
|
||||
string? Challenge,
|
||||
string? Nonce,
|
||||
int? AcceptedNoticeVersion);
|
||||
|
||||
public sealed record NativeBifrostRegistrationResponse(
|
||||
Guid UserAccountId,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ public sealed class DeskOptionsTests
|
|||
Options.Create(new StripeOptions()),
|
||||
Options.Create(new BifrostHostOptions()),
|
||||
Options.Create(new ConceptVoteOptions()),
|
||||
Options.Create(Bind(values))).Validate();
|
||||
Options.Create(Bind(values)),
|
||||
Options.Create(new RegistrationOptions())).Validate();
|
||||
}
|
||||
|
||||
private static void Refused(Dictionary<string, string?> values, string expectedFragment)
|
||||
|
|
|
|||
|
|
@ -363,7 +363,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
|
||||
MaxTrackedKeys = maxTrackedKeys
|
||||
}),
|
||||
Options.Create(new DeskOptions())).Validate();
|
||||
Options.Create(new DeskOptions()),
|
||||
Options.Create(new RegistrationOptions())).Validate();
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public sealed class MigrationDiscoveryTests
|
|||
"20261022163159_AddOpenIddict",
|
||||
"20261109200000_AddSealedBallotMotions",
|
||||
"20261109210000_AddMindKeys",
|
||||
"20261111090000_AddConsentReceipts",
|
||||
];
|
||||
|
||||
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
|
||||
|
|
|
|||
|
|
@ -838,7 +838,7 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
|
||||
private static async Task<string> RegisterAsync(HttpClient client, string identity)
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity });
|
||||
using var response = await RegistrationKit.RegisterAsync(client, identity);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return identity.ToLowerInvariant();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,16 +45,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
request.Content = JsonContent.Create(new
|
||||
{
|
||||
identity = $"native-{Guid.NewGuid():N}",
|
||||
displayName = "Native Bifrost User",
|
||||
returnUrl = "/App"
|
||||
});
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
using var response = await RegistrationKit.RegisterAsync(
|
||||
client, $"native-{Guid.NewGuid():N}", "Native Bifrost User", "/App", anonymous: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
|
@ -79,15 +71,8 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
using var client = _factory.CreateClient();
|
||||
var identity = $"native-{Guid.NewGuid():N}";
|
||||
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
firstRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||
firstRequest.Content = JsonContent.Create(new { identity, displayName = "First" });
|
||||
using var firstResponse = await client.SendAsync(firstRequest);
|
||||
|
||||
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
secondRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||
secondRequest.Content = JsonContent.Create(new { identity = identity.ToUpperInvariant(), displayName = "Second" });
|
||||
using var secondResponse = await client.SendAsync(secondRequest);
|
||||
using var firstResponse = await RegistrationKit.RegisterAsync(client, identity, "First", anonymous: true);
|
||||
using var secondResponse = await RegistrationKit.RegisterAsync(client, identity.ToUpperInvariant(), "Second", anonymous: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode);
|
||||
|
|
|
|||
367
tests/Bifrost.Web.Tests/RegistrationGateTests.cs
Normal file
367
tests/Bifrost.Web.Tests/RegistrationGateTests.cs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class RegistrationGateFactory : TestWebApplicationFactory
|
||||
{
|
||||
public ManualClock Time { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<TimeProvider>();
|
||||
services.AddSingleton<TimeProvider>(Time);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The anonymous register is gated by consent and a proof of work; every refusal must create nothing.
|
||||
public sealed class RegistrationGateTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
|
||||
{
|
||||
private static string NewIdentity() => $"gate-{Guid.NewGuid():N}";
|
||||
|
||||
private HttpClient Client() => factory.CreateClient();
|
||||
|
||||
private (int Accounts, int Receipts) Counts()
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
|
||||
}
|
||||
|
||||
private async Task<(string Challenge, string Nonce)> SolvedAsync(HttpClient client) =>
|
||||
await RegistrationKit.SolveAsync(client);
|
||||
|
||||
private async Task AssertRefusedCreatesNothingAsync(HttpClient client, object body, string? expectedFragment = null)
|
||||
{
|
||||
var before = Counts();
|
||||
using var response = await RegistrationKit.PostAsync(client, body, anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
if (expectedFragment is not null)
|
||||
{
|
||||
Assert.Contains(expectedFragment, await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
Assert.Equal(before, Counts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_consent_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce },
|
||||
$"current version {TrainingNotice.Version}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)]
|
||||
public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version)
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("")]
|
||||
public async Task A_garbage_challenge_is_refused_and_creates_nothing(string challenge)
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce = "1", acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tampered_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, _) = await SolvedAsync(client);
|
||||
var tampered = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
|
||||
var nonce = RegistrationKit.Solve(tampered, 4);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge = tampered, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_nonce_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var challenge = issued.GetProperty("challenge").GetString()!;
|
||||
var difficulty = issued.GetProperty("difficulty").GetInt32();
|
||||
var correct = RegistrationKit.Solve(challenge, difficulty);
|
||||
// The next nonce that does not meet the difficulty.
|
||||
var wrong = Enumerable.Range(0, int.MaxValue)
|
||||
.Select(i => i.ToString())
|
||||
.First(n => n != correct && RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) < difficulty);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce = wrong, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
// Each malformed shape is padded until it meets the difficulty, so only the nonce-format guard can refuse it.
|
||||
[Theory]
|
||||
[InlineData("x", "")]
|
||||
[InlineData("-", "")]
|
||||
[InlineData("", "e")]
|
||||
[InlineData("000000000000000000000", "")]
|
||||
public async Task A_malformed_nonce_is_refused_even_when_its_hash_would_pass(string prefix, string suffix)
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, _) = await SolvedAsync(client);
|
||||
var nonce = Enumerable.Range(0, int.MaxValue)
|
||||
.Select(i => prefix + i + suffix)
|
||||
.First(n => RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) >= 4);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_consumed_challenge_cannot_be_replayed()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
var first = NewIdentity();
|
||||
var body = (string identity) => new { identity, challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version };
|
||||
|
||||
using var accepted = await RegistrationKit.PostAsync(client, body(first), anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
var before = Counts();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(client, body(NewIdentity()));
|
||||
Assert.Equal(before, Counts());
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Single(db.UserAccounts.Where(x => x.NormalizedBifrostIdentity == first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_nonce_solved_for_one_challenge_does_not_pass_another()
|
||||
{
|
||||
using var client = Client();
|
||||
var (_, nonceForFirst) = await SolvedAsync(client);
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var second = issued.GetProperty("challenge").GetString()!;
|
||||
var difficulty = issued.GetProperty("difficulty").GetInt32();
|
||||
// Skip the rare case where the nonce happens to also solve the second challenge.
|
||||
if (RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(second + ":" + nonceForFirst))) >= difficulty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge = second, nonce = nonceForFirst, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_stores_the_receipt_with_the_account()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
|
||||
using var response = await RegistrationKit.RegisterAsync(client, identity, anonymous: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
|
||||
var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id);
|
||||
Assert.Equal(1, receipt.NoticeVersion);
|
||||
Assert.Null(receipt.WithdrawnAtUtc);
|
||||
Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_stamps_once_and_touches_only_the_caller()
|
||||
{
|
||||
using var client = Client();
|
||||
var mine = NewIdentity();
|
||||
var theirs = NewIdentity();
|
||||
(await RegistrationKit.RegisterAsync(client, mine, anonymous: true)).Dispose();
|
||||
(await RegistrationKit.RegisterAsync(client, theirs, anonymous: true)).Dispose();
|
||||
|
||||
async Task<JsonElement> WithdrawAsync()
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Bifrost-Identity", mine);
|
||||
using var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
}
|
||||
|
||||
var first = await WithdrawAsync();
|
||||
factory.Time.Advance(TimeSpan.FromHours(1));
|
||||
var second = await WithdrawAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var myReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == mine);
|
||||
var theirReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == theirs);
|
||||
Assert.NotNull(myReceipt.WithdrawnAtUtc);
|
||||
Assert.Equal(myReceipt.WithdrawnAtUtc, first.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
|
||||
Assert.Equal(first.GetProperty("withdrawnAtUtc").GetDateTimeOffset(), second.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
|
||||
Assert.Null(theirReceipt.WithdrawnAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_withdraw_is_refused_and_changes_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
(await RegistrationKit.RegisterAsync(client, identity, anonymous: true)).Dispose();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
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()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
var notice = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/notice");
|
||||
|
||||
Assert.Equal(1, notice.GetProperty("version").GetInt32());
|
||||
Assert.Contains("Discord", notice.GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_challenge_response_is_not_cached()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
using var response = await client.GetAsync("/auth/bifrost/challenge");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(18, true)]
|
||||
[InlineData(30, true)]
|
||||
[InlineData(31, false)]
|
||||
public void Difficulty_outside_one_to_thirty_is_refused_at_startup(int difficulty, bool valid)
|
||||
{
|
||||
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } };
|
||||
Action validate = () => new StartupConfigurationValidator(
|
||||
new ConfigurationBuilder().Build(),
|
||||
new TestingEnvironment(),
|
||||
Options.Create(new GitHubOAuthOptions()),
|
||||
Options.Create(new GitHubAppOptions()),
|
||||
Options.Create(new HeimdallOptions()),
|
||||
Options.Create(new StripeOptions()),
|
||||
Options.Create(new BifrostHostOptions()),
|
||||
Options.Create(new ConceptVoteOptions()),
|
||||
Options.Create(new DeskOptions()),
|
||||
Options.Create(options)).Validate();
|
||||
|
||||
if (valid)
|
||||
{
|
||||
validate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("Difficulty", Assert.Throws<InvalidOperationException>(validate).Message);
|
||||
}
|
||||
|
||||
Assert.Equal(18, new RegistrationOptions().Captcha.Difficulty);
|
||||
}
|
||||
|
||||
private sealed class TestingEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Testing";
|
||||
public string ApplicationName { get; set; } = "Bifrost.Web";
|
||||
public string ContentRootPath { get; set; } = string.Empty;
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
239
tests/Bifrost.Web.Tests/RegistrationLimitTests.cs
Normal file
239
tests/Bifrost.Web.Tests/RegistrationLimitTests.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
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 An_IPv6_caller_owns_its_slash_64_and_a_mapped_IPv4_shares_its_IPv4_key()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
string[] sameSlash64 = ["2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2:aaaa::3", "2001:db8:1:2:ffff:ffff:ffff:ffff", "2001:db8:1:2::5"];
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
foreach (var peer in sameSlash64)
|
||||
{
|
||||
using var response = await ChallengeAsync(client, peer);
|
||||
statuses.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
[HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests],
|
||||
statuses);
|
||||
using var otherSlash64 = await ChallengeAsync(client, "2001:db8:1:3::1");
|
||||
Assert.Equal(HttpStatusCode.OK, otherSlash64.StatusCode);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, "203.0.113.50");
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var mapped = await ChallengeAsync(client, "::ffff:203.0.113.50");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, mapped.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
|
||||
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
|
@ -42,7 +42,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
|
||||
Assert.Empty(db.UserAccounts);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var account = await service.RegisterNativeAsync(name, null, default);
|
||||
var account = await service.RegisterNativeAsync(name, null, 1, default);
|
||||
|
||||
Assert.Equal(name, account.NormalizedBifrostIdentity);
|
||||
Assert.Single(db.UserAccounts);
|
||||
|
|
@ -112,7 +112,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
|
||||
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
|
@ -147,7 +147,7 @@ public sealed class ReservedIdentityTests
|
|||
public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input)
|
||||
{
|
||||
var service = NewService(out var db);
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, default));
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, 1, default));
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
||||
var account = new UserAccount { DisplayName = input };
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
|
|||
{
|
||||
using var client = CreateClient(HostClientOptions());
|
||||
var identity = $"member-{Guid.NewGuid():N}";
|
||||
using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }))
|
||||
using (var register = await RegistrationKit.RegisterAsync(client, identity))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, register.StatusCode);
|
||||
}
|
||||
|
|
|
|||
63
tests/Bifrost.Web.Tests/Support/RegistrationKit.cs
Normal file
63
tests/Bifrost.Web.Tests/Support/RegistrationKit.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
/// <summary>Registers through the real gate: fetch a challenge, solve it, post with the notice version.</summary>
|
||||
public static class RegistrationKit
|
||||
{
|
||||
public static async Task<(string Challenge, string Nonce)> SolveAsync(HttpClient client)
|
||||
{
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var challenge = issued.GetProperty("challenge").GetString()!;
|
||||
return (challenge, Solve(challenge, issued.GetProperty("difficulty").GetInt32()));
|
||||
}
|
||||
|
||||
public static string Solve(string challenge, int difficulty)
|
||||
{
|
||||
for (long nonce = 0; ; nonce++)
|
||||
{
|
||||
var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + text))) >= difficulty)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> RegisterAsync(
|
||||
HttpClient client,
|
||||
string identity,
|
||||
string? displayName = null,
|
||||
string? returnUrl = null,
|
||||
bool anonymous = false)
|
||||
{
|
||||
var (challenge, nonce) = await SolveAsync(client);
|
||||
return await PostAsync(client, new
|
||||
{
|
||||
identity,
|
||||
displayName = displayName ?? identity,
|
||||
returnUrl,
|
||||
challenge,
|
||||
nonce,
|
||||
acceptedNoticeVersion = TrainingNotice.Version
|
||||
}, anonymous);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostAsync(HttpClient client, object body, bool anonymous = false)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
|
||||
{
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
if (anonymous)
|
||||
{
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
}
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,16 @@ public sealed class TestAuthHandler(
|
|||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
// A test may sign in as a native account by naming its Bifrost identity; only that claim is sent, so no GitHub claim also matches.
|
||||
if (Request.Headers.TryGetValue("X-Test-Bifrost-Identity", out var nativeIdentity))
|
||||
{
|
||||
var native = new ClaimsIdentity(
|
||||
[new Claim(BifrostClaimTypes.BifrostIdentity, nativeIdentity.ToString())],
|
||||
SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(
|
||||
new AuthenticationTicket(new ClaimsPrincipal(native), SchemeName)));
|
||||
}
|
||||
|
||||
// A test may sign in as another account by naming its GitHub id and login.
|
||||
var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345";
|
||||
var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin";
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["ConceptVotes:CastPermits"] = "100000",
|
||||
["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