Registration: sign-up form with proof-of-work and a no-JS time-lock fallback (#56)

This commit is contained in:
Heimdall 2026-11-11 11:17:25 +00:00
commit 028bc2fdf6
16 changed files with 1674 additions and 121 deletions

View file

@ -135,6 +135,7 @@ builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and option
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
builder.Services.AddSingleton<RegistrationChallengeService>();
builder.Services.AddSingleton<RegistrationRateLimit>();
builder.Services.AddScoped<NativeRegistration>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -389,8 +390,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
app.MapPost("/auth/bifrost/register", async (
NativeBifrostRegistrationRequest request,
HttpContext httpContext,
BifrostIdentityService bifrostIdentityService,
RegistrationChallengeService challenges,
NativeRegistration registration,
RegistrationRateLimit rateLimit,
CancellationToken cancellationToken) =>
{
@ -399,44 +399,142 @@ app.MapPost("/auth/bifrost/register", async (
return refused;
}
// Consent first, then the proof of work; a refusal here creates nothing.
if (request.AcceptedNoticeVersion != TrainingNotice.Version)
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion),
cancellationToken);
if (outcome.Refusal is not null)
{
return Results.Text(
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
statusCode: StatusCodes.Status400BadRequest);
return outcome.Refusal;
}
if (!challenges.TryConsume(request.Challenge, request.Nonce))
if (outcome.Account is not { } userAccount)
{
return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest);
return Results.Text(outcome.Message!, statusCode: outcome.Status);
}
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
}).AllowAnonymous();
// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge.
app.MapGet("/auth/bifrost/register", (
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<RegistrationOptions> registrationOptions) =>
{
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK);
}).AllowAnonymous();
// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead
// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no
// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with
// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent.
// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie.
app.MapPost("/auth/bifrost/register/form", async (
HttpContext httpContext,
NativeRegistration registration,
RegistrationChallengeService challenges,
RegistrationRateLimit rateLimit,
IOptions<BifrostHostOptions> hostOptions,
IOptions<RegistrationOptions> registrationOptions,
CancellationToken cancellationToken) =>
{
if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value))
{
return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden);
}
if (rateLimit.Refuse(httpContext) is { } refused)
{
return refused;
}
if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
{
return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType);
}
// The form is a handful of short fields: refuse a big body before reading any of it.
const int MaxFormBytes = 16 * 1024;
if (httpContext.Request.ContentLength > MaxFormBytes)
{
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
}
if (httpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
{
limit.MaxRequestBodySize = MaxFormBytes;
}
IFormCollection form;
try
{
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
request.Identity,
request.DisplayName,
TrainingNotice.Version,
cancellationToken);
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
bifrostIdentityService.BuildPrincipal(userAccount));
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
return JsonResult(
new NativeBifrostRegistrationResponse(
userAccount.Id,
userAccount.BifrostIdentity,
userAccount.DisplayName,
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
returnUrl),
appJsonSerializerOptions);
form = await httpContext.Request.ReadFormAsync(cancellationToken);
}
catch (BifrostIdentityException error)
catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
}
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
{
return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest);
}
string Field(string name) => form[name].FirstOrDefault() ?? "";
var returnPath = LocalReturnPath.Parse(Field("returnUrl"));
// The checkbox is the consent act: without it the posted notice version is not taken as accepted.
int? accepted = Field("consent") == "yes" && int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version)
? version
: null;
var identity = Field("identity");
var displayName = Field("displayName");
var challenge = Field("challenge");
var outcome = await registration.RunAsync(
httpContext,
new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted),
cancellationToken);
if (outcome.Refusal is not null)
{
return outcome.Refusal;
}
if (outcome.Account is not null)
{
// A Location header takes ASCII only; anything else is not a path worth keeping.
var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value;
httpContext.Response.Headers.CacheControl = "no-store";
httpContext.Response.Headers.Location = target;
return Results.StatusCode(StatusCodes.Status303SeeOther);
}
// A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its
// challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked.
var options = registrationOptions.Value;
return RegistrationFormResult(
httpContext,
challenges,
options,
new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field),
outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null,
outcome.Status);
static string Truncate(string text) => text.Length <= 256 ? text : text[..256];
}).AllowAnonymous();
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
@ -814,6 +912,36 @@ static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, Can
}
}
// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full).
static IResult RegistrationFormResult(
HttpContext httpContext,
RegistrationChallengeService challenges,
RegistrationOptions options,
RegistrationPageModel model,
string? keepChallenge,
int statusCode)
{
var token = keepChallenge;
var difficulty = options.Captcha.Difficulty;
if (token is null)
{
var issued = challenges.Issue();
if (issued is null)
{
return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
token = issued.Challenge;
difficulty = issued.Difficulty;
}
httpContext.Response.Headers.CacheControl = "no-store";
return Results.Content(
RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }),
"text/html; charset=utf-8",
statusCode: statusCode);
}
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
{
httpContext.Response.ContentType = "text/html; charset=utf-8";
@ -870,7 +998,8 @@ public sealed record NativeBifrostRegistrationRequest(
string? ReturnUrl,
string? Challenge,
string? Nonce,
int? AcceptedNoticeVersion);
int? AcceptedNoticeVersion,
bool? Waited = null);
public sealed record NativeBifrostRegistrationResponse(
Guid UserAccountId,