Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
199 lines
7.9 KiB
C#
199 lines
7.9 KiB
C#
using System.Security.Claims;
|
|
using DodoSSH.Domain;
|
|
using DodoSSH.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Npgsql;
|
|
|
|
namespace DodoSSH.Api.Authorization;
|
|
|
|
/// <summary>Resolves the authenticated caller, provisioning them on first sight.</summary>
|
|
public interface ICurrentUserContext
|
|
{
|
|
/// <summary>
|
|
/// Returns the caller's account, creating it if this is their first authenticated request.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">The request is not authenticated.</exception>
|
|
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request-scoped caller identity with just-in-time provisioning.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
|
|
/// email means anyone who can obtain a token bearing a victim's email address — from any configured
|
|
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
|
|
/// anything that would.</b> This class used to claim pending team invitations on the way past, which
|
|
/// made a provider's assertion about an address into a decision about who joins a team; invitations are
|
|
/// gone and membership is granted only to an account somebody named — see
|
|
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class CurrentUserContext(
|
|
IHttpContextAccessor accessor,
|
|
DodoDbContext database,
|
|
IOptions<Setup.OidcOptions> oidcOptions,
|
|
TimeProvider clock)
|
|
: ICurrentUserContext
|
|
{
|
|
/// <summary>
|
|
/// How stale <see cref="UserAccount.LastSeenAtUtc"/> may get before a request refreshes it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// An hour, and coarse on purpose in both directions. Writing it on every request would put an
|
|
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
|
|
/// the xmin concurrency token — would start losing races between a user's own overlapping
|
|
/// requests. Writing it never is what made the old "last active" column impossible to offer
|
|
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not".
|
|
/// </remarks>
|
|
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
|
|
|
|
private UserAccount? cached;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (cached is not null)
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
var principal = accessor.HttpContext?.User
|
|
?? throw new InvalidOperationException("No HTTP context is available.");
|
|
|
|
if (principal.Identity?.IsAuthenticated != true)
|
|
{
|
|
throw new InvalidOperationException("The request is not authenticated.");
|
|
}
|
|
|
|
var issuer = RequireClaim(principal, "iss");
|
|
var subject = RequireClaim(principal, "sub");
|
|
var options = oidcOptions.Value;
|
|
var email = principal.FindFirstValue(options.EmailClaim);
|
|
var displayName = principal.FindFirstValue(options.NameClaim);
|
|
|
|
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (existing is null)
|
|
{
|
|
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return cached;
|
|
}
|
|
|
|
cached = existing;
|
|
|
|
await RefreshLastSeenAsync(existing, cancellationToken).ConfigureAwait(false);
|
|
|
|
return cached;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records that this account is active.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
|
|
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
|
/// requests from one user would each read the row, each set the timestamp, and the second would
|
|
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
|
/// <para>
|
|
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
|
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
|
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
|
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
|
/// against the version from before the update and failed, so enrolling more than an hour after first
|
|
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
|
{
|
|
var now = clock.GetUtcNow();
|
|
|
|
if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var written = await database.Users
|
|
.Where(u => u.Id == user.Id
|
|
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
|
.ExecuteUpdateAsync(
|
|
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (written > 0)
|
|
{
|
|
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
|
database.Users.SingleOrDefaultAsync(
|
|
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
|
|
cancellationToken);
|
|
|
|
private async Task<UserAccount> ProvisionAsync(
|
|
string issuer,
|
|
string subject,
|
|
string? email,
|
|
string? displayName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var now = clock.GetUtcNow();
|
|
|
|
var user = new UserAccount
|
|
{
|
|
Id = Guid.CreateVersion7(),
|
|
Issuer = issuer,
|
|
Subject = subject,
|
|
Email = email,
|
|
DisplayName = displayName,
|
|
Status = UserStatus.Active,
|
|
CreatedAtUtc = now,
|
|
UpdatedAtUtc = now,
|
|
LastSeenAtUtc = now,
|
|
};
|
|
|
|
database.Users.Add(user);
|
|
|
|
try
|
|
{
|
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
return user;
|
|
}
|
|
catch (DbUpdateException exception)
|
|
when (string.Equals(
|
|
(exception.InnerException as PostgresException)?.SqlState,
|
|
PostgresErrorCodes.UniqueViolation,
|
|
StringComparison.Ordinal))
|
|
{
|
|
// Two concurrent first requests from the same new user. The unique index on
|
|
// (issuer, subject) is what makes this safe: one insert wins and the other reads it
|
|
// back, rather than both proceeding with a duplicate account.
|
|
database.Entry(user).State = EntityState.Detached;
|
|
|
|
var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
|
|
if (winner is not null)
|
|
{
|
|
return winner;
|
|
}
|
|
|
|
// The unique violation came from something other than the race we expected — for
|
|
// instance the partial unique index on email. Do not swallow it.
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static string RequireClaim(ClaimsPrincipal principal, string claimType) =>
|
|
principal.FindFirstValue(claimType)
|
|
?? throw new InvalidOperationException(
|
|
$"The access token is missing the required '{claimType}' claim.");
|
|
}
|