Registration: sign-up form with proof-of-work and a no-JS time-lock fallback (#56)
This commit is contained in:
parent
b2e0bb2514
commit
028bc2fdf6
16 changed files with 1674 additions and 121 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue