MergeStatus page: the member's merges and undo by fresh proof of every moved login
Test files: the bearer list is kept apart from the surface it is checked against; wording tests use the Razor encoder. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec28dffba1
commit
5ddfca9b55
4 changed files with 302 additions and 6 deletions
|
|
@ -81,6 +81,9 @@ public static class MergeWording
|
|||
? $"Approved with a passkey; you have until {Date(graceEnds)} to undo."
|
||||
: $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo.";
|
||||
|
||||
/// <summary>Whole days from <paramref name="now"/> to <paramref name="end"/>, rounded up, at least one while the window is open.</summary>
|
||||
public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays));
|
||||
|
||||
public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left";
|
||||
|
||||
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
|
||||
|
|
|
|||
|
|
@ -1,2 +1,100 @@
|
|||
@page
|
||||
@model Bifrost.Web.Pages.Membership.MergeStatusModel
|
||||
@{
|
||||
ViewData["Title"] = "Merge status";
|
||||
}
|
||||
|
||||
<section class="panel">
|
||||
<span class="eyebrow">Account merge</span>
|
||||
<h1>Merge status</h1>
|
||||
<p class="panel-intro">
|
||||
A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for
|
||||
every login that moved.
|
||||
</p>
|
||||
|
||||
@if (Model.ResultMessage is not null)
|
||||
{
|
||||
<div class="callout">@Model.ResultMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.UndoMessage is not null)
|
||||
{
|
||||
<div class="callout">@Model.UndoMessage</div>
|
||||
}
|
||||
|
||||
@if (Model.Merges.Count == 0)
|
||||
{
|
||||
<p class="muted">There is no merge to show for this account.</p>
|
||||
}
|
||||
|
||||
@foreach (var merge in Model.Merges)
|
||||
{
|
||||
<article class="status-banner">
|
||||
<strong>@merge.State</strong>
|
||||
@if (merge.Why is not null)
|
||||
{
|
||||
<p class="muted">@merge.Why</p>
|
||||
}
|
||||
@if (merge.Outcome is not null)
|
||||
{
|
||||
<p class="muted">@merge.Outcome</p>
|
||||
}
|
||||
<ul>
|
||||
@if (merge.Confirmed is not null)
|
||||
{
|
||||
<li>Confirmed @merge.Confirmed</li>
|
||||
}
|
||||
@if (merge.Committed is not null)
|
||||
{
|
||||
<li>Merged @merge.Committed</li>
|
||||
}
|
||||
@if (merge.GraceEnds is not null)
|
||||
{
|
||||
<li>Undo window ends @merge.GraceEnds</li>
|
||||
}
|
||||
@if (merge.Final is not null)
|
||||
{
|
||||
<li>Final @merge.Final</li>
|
||||
}
|
||||
@if (merge.SurvivorProofs.Count > 0)
|
||||
{
|
||||
<li>Kept account proved with: @string.Join(", ", merge.SurvivorProofs)</li>
|
||||
}
|
||||
@if (merge.FoldedProofs.Count > 0)
|
||||
{
|
||||
<li>Folded account proved with: @string.Join(", ", merge.FoldedProofs)</li>
|
||||
}
|
||||
</ul>
|
||||
@if (merge.DaysLeft is not null)
|
||||
{
|
||||
<p><span class="pill warning">@merge.DaysLeft</span></p>
|
||||
}
|
||||
@if (merge.TierNote is not null)
|
||||
{
|
||||
<p class="muted">@merge.TierNote</p>
|
||||
}
|
||||
|
||||
@if (merge.CanUndo)
|
||||
{
|
||||
@if (merge.Ready)
|
||||
{
|
||||
<form method="post" asp-page-handler="Undo">
|
||||
<input type="hidden" name="mergeId" value="@merge.Id" />
|
||||
<button class="button" type="submit">Undo this merge</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted">To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.</p>
|
||||
@foreach (var provider in merge.NeedProof)
|
||||
{
|
||||
<form method="post" asp-page-handler="ProveForUndo">
|
||||
<input type="hidden" name="via" value="@provider" />
|
||||
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.LinkedAccountsModel.ProviderName(provider)</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,206 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Pages.Membership;
|
||||
|
||||
/// <summary>Skeleton: the member's own merge page. Filled in by the cut's next commits.</summary>
|
||||
/// <summary>
|
||||
/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in
|
||||
/// <see cref="MergeWording"/>'s closed words, and the undo. It reads merge rows and writes none: undo is the saga's
|
||||
/// <see cref="AccountMergeSaga.RequestUndoAsync"/> followed by its own <see cref="AccountMergeSaga.AdvanceAsync"/>, with fresh proofs
|
||||
/// of every moved login held by <see cref="MergeProofs"/>. A person who signed in through a login that moved is the survivor here, so
|
||||
/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and
|
||||
/// no account name, id, email or login of either side is rendered.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public sealed class MergeStatusModel : PageModel
|
||||
public sealed class MergeStatusModel(
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
BifrostDbContext dbContext,
|
||||
AccountMergeSaga saga,
|
||||
MergeProofs proofs,
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<MergeStatusModel> logger) : PageModel
|
||||
{
|
||||
public void OnGet()
|
||||
private const int MaxListed = 5;
|
||||
private const string UndoParameter = "undo";
|
||||
private const string LoginsUnproved = "logins_unproved";
|
||||
|
||||
public sealed record MergeRow(
|
||||
Guid Id,
|
||||
string State,
|
||||
string? Why,
|
||||
string? Outcome,
|
||||
string? Confirmed,
|
||||
string? Committed,
|
||||
string? GraceEnds,
|
||||
string? Final,
|
||||
string? DaysLeft,
|
||||
string? TierNote,
|
||||
IReadOnlyList<string> SurvivorProofs,
|
||||
IReadOnlyList<string> FoldedProofs,
|
||||
bool CanUndo,
|
||||
bool Ready,
|
||||
IReadOnlyList<string> NeedProof);
|
||||
|
||||
public IReadOnlyList<MergeRow> Merges { get; private set; } = [];
|
||||
|
||||
public string? ResultMessage { get; private set; }
|
||||
|
||||
public string? UndoMessage { get; private set; }
|
||||
|
||||
public static string ProviderName(string provider) =>
|
||||
HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider";
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
|
||||
UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null;
|
||||
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
var me = actor.UserAccount?.Id;
|
||||
var rows = me is { } id
|
||||
? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken)
|
||||
: [];
|
||||
if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId)
|
||||
&& await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named)
|
||||
{
|
||||
rows.Add(named);
|
||||
}
|
||||
|
||||
Merges = [.. rows.Select(x => Row(x, me))];
|
||||
}
|
||||
|
||||
/// <summary>Starts a fresh sign-in of this account through <paramref name="via"/>, held in memory until the undo takes it.</summary>
|
||||
public async Task<IActionResult> OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken)
|
||||
{
|
||||
if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId)
|
||||
{
|
||||
return Result(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var started = await heimdallAuthService.StartMergeProveSelfAsync(
|
||||
accountId, via, HeimdallLinkReturn, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
|
||||
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Redirect(started.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
logger.LogWarning("Merge undo proof start refused: {Reason}", error.Message);
|
||||
return Result(HeimdallLinkOutcome.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would.</summary>
|
||||
public async Task<IActionResult> OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id)
|
||||
|| await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge
|
||||
|| merge.SurvivorUserAccountId != me.Id)
|
||||
{
|
||||
return Undone("not_survivor");
|
||||
}
|
||||
|
||||
if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready)
|
||||
{
|
||||
return Undone(LoginsUnproved);
|
||||
}
|
||||
|
||||
var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken);
|
||||
if (outcome is UndoOutcome.Requested)
|
||||
{
|
||||
proofs.Clear(me.Id);
|
||||
try
|
||||
{
|
||||
await saga.AdvanceAsync(id, cancellationToken);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
// The saga's stored state is the truth and the sweeper resumes it.
|
||||
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name);
|
||||
}
|
||||
}
|
||||
|
||||
return Undone(MergeWording.UndoCode(outcome));
|
||||
}
|
||||
|
||||
private const string HeimdallLinkReturn = "/Membership/MergeStatus";
|
||||
|
||||
private MergeRow Row(AccountMerge merge, Guid? me)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end;
|
||||
var canUndo = open && me == merge.SurvivorUserAccountId;
|
||||
var ready = false;
|
||||
IReadOnlyList<string> need = [];
|
||||
if (canUndo)
|
||||
{
|
||||
switch (proofs.PeekUndo(me!.Value, MovedIds(merge)))
|
||||
{
|
||||
case UndoPeekResult.Ready { Proofs.Count: > 0 }:
|
||||
ready = true;
|
||||
break;
|
||||
case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved:
|
||||
need = unproved.Providers;
|
||||
break;
|
||||
default:
|
||||
need = HeimdallAuthService.LinkableProviders;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now;
|
||||
return new MergeRow(
|
||||
merge.Id,
|
||||
MergeWording.State(merge.State),
|
||||
merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null,
|
||||
merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved
|
||||
or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed
|
||||
? MergeWording.Outcome(merge.LastOutcome)
|
||||
: null,
|
||||
merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null,
|
||||
merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null,
|
||||
merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null,
|
||||
merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null,
|
||||
inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null,
|
||||
inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null,
|
||||
merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [],
|
||||
[.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)],
|
||||
canUndo,
|
||||
ready,
|
||||
need);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> MovedIds(AccountMerge merge)
|
||||
{
|
||||
try
|
||||
{
|
||||
return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize<string[]>(json) ?? [] : [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult Undone(string code) =>
|
||||
RedirectToPage(null, new Dictionary<string, string?> { [UndoParameter] = code });
|
||||
|
||||
private IActionResult Result(HeimdallLinkOutcome outcome) =>
|
||||
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ public sealed class MergeUndoPageTests
|
|||
/// <summary>Everything a response put on the wire: bodies, Location, Set-Cookie. The privacy test reads it at the end.</summary>
|
||||
public List<string> Surface { get; } = [];
|
||||
|
||||
/// <summary>Every bearer the test redeemed; none may appear on the surface.</summary>
|
||||
public List<string> Bearers { get; } = [];
|
||||
|
||||
public void Dispose() => Rig.Dispose();
|
||||
|
||||
public async Task<string> SignInThrough(string provider) => await Host.SignInSessionAsync(OwnerOf(Rig, Folded.Logins[provider]), provider);
|
||||
|
|
@ -82,7 +85,7 @@ public sealed class MergeUndoPageTests
|
|||
|
||||
var bearer = Host.Token(Survivor.HeimdallId);
|
||||
Rig.Moves.Issue(Survivor.HeimdallId, Folded.Logins[provider], bearer: bearer);
|
||||
Surface.Add("bearer-under-test " + bearer);
|
||||
Bearers.Add(bearer);
|
||||
var done = await Host.PostCompleteAsync(
|
||||
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, Survivor.HeimdallId, bearer, provider: provider) },
|
||||
$"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}");
|
||||
|
|
@ -241,11 +244,12 @@ public sealed class MergeUndoPageTests
|
|||
|
||||
secrets.AddRange(w.Survivor.Logins.Values);
|
||||
secrets.AddRange(w.Folded.Logins.Values);
|
||||
secrets.AddRange(w.Surface.Where(x => x.StartsWith("bearer-under-test ")).Select(x => x["bearer-under-test ".Length..]));
|
||||
secrets.AddRange(w.Bearers);
|
||||
Assert.Equal(2, w.Bearers.Count);
|
||||
Assert.All(secrets, x => Assert.DoesNotContain(x, everything));
|
||||
Assert.DoesNotContain("PUID-CANARY", everything);
|
||||
Assert.DoesNotContain("username-canary", everything);
|
||||
}
|
||||
|
||||
private static string Enc(string text) => WebUtility.HtmlEncode(text);
|
||||
private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue