Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@ public async Task<ErrorOr<UserDto>> Handle(
// their own Email field but we care about collisions across both.
if (!string.IsNullOrWhiteSpace(command.Email))
{
var normalizedEmail = command.Email.ToUpperInvariant();
var personEmailTaken = await session.Query<Person>()
.Where(p => p.Email == command.Email && !p.IsDeleted)
.Where(p => p.NormalizedEmail == normalizedEmail && !p.IsDeleted)
.AnyAsync(ct);
if (personEmailTaken)
return DomainErrors.User.EmailTaken(command.Email);

var groupEmailTaken = await session.Query<Group>()
.Where(g => g.Email == command.Email && !g.IsDeleted)
.Where(g => g.Email != null && g.Email.ToUpper() == normalizedEmail && !g.IsDeleted)
.AnyAsync(ct);
if (groupEmailTaken)
return DomainErrors.User.EmailTaken(command.Email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,15 @@ public async Task<ErrorOr<UserDto>> Handle(
if (command.Email.HasValue && !string.IsNullOrWhiteSpace(command.Email.Value))
{
var email = command.Email.Value;
var normalizedEmail = email.ToUpperInvariant();
var personEmailTaken = await session.Query<Person>()
.Where(p => p.Email == email && p.Id != command.UserId && !p.IsDeleted)
.Where(p => p.NormalizedEmail == normalizedEmail && p.Id != command.UserId && !p.IsDeleted)
.AnyAsync(ct);
if (personEmailTaken)
return DomainErrors.User.EmailTaken(email);

var groupEmailTaken = await session.Query<Group>()
.Where(g => g.Email == email && !g.IsDeleted)
.Where(g => g.Email != null && g.Email.ToUpper() == normalizedEmail && !g.IsDeleted)
.AnyAsync(ct);
if (groupEmailTaken)
return DomainErrors.User.EmailTaken(email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,10 @@ public static WebApplication MapPasskeyEndpoints(this WebApplication application

// Sign in
var user = await session.LoadAsync<ApplicationUser>(storedCredential.UserId);
if (user is null || !user.IsActive)
// Defense-in-depth: passkey login loads the user directly (bypassing
// the Identity store's filters), so reject deleted users explicitly —
// not just inactive ones — closing the soft-delete auth-bypass.
if (user is null || !user.IsActive || user.IsDeleted)
{
ModgudMeters.RecordLogin(ModgudMeters.LoginMethod.Passkey, ModgudMeters.LoginOutcome.Failure);
return Results.Json(new { Message = "Invalid credentials" }, statusCode: 401);
Expand Down
14 changes: 9 additions & 5 deletions src/dotnet/Modgud.Authentication/Api/Admin/RecoveryCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public static async Task<int> RunAsync(IServiceProvider services, string[] args,
"reset-2fa" => await Reset2FaAsync(session, userManager, args),
"set-email" => await SetEmailAsync(session, userManager, args),
"magic-link" => await MagicLinkAsync(session, scope.ServiceProvider, args, conf, env),
"rebuild-projections" => await RebuildProjectionsAsync(scope.ServiceProvider),
"rebuild-projections" => await RebuildProjectionsAsync(scope.ServiceProvider, realmSlug),
"bootstrap-admin" => await BootstrapAdminAsync(scope.ServiceProvider, args, realmSlug),
"migrate-cc-credentials" => await MigrateClientCredentialsAsync(scope.ServiceProvider, args, realmSlug),
"realm-add-domain" => await RealmAddDomainAsync(scope.ServiceProvider, args),
Expand Down Expand Up @@ -248,11 +248,12 @@ private static async Task<int> SetEmailAsync(

// Uniqueness guard — same check UpdateUserCommand runs. The polymorphic
// Principal projection is inline, so this is strongly consistent.
var normalizedEmail = newEmail.ToUpperInvariant();
var personConflict = await session.Query<Modgud.Authorization.Principals.Person>()
.Where(p => p.Email == newEmail && p.Id != user.Id && !p.IsDeleted)
.Where(p => p.NormalizedEmail == normalizedEmail && p.Id != user.Id && !p.IsDeleted)
.AnyAsync();
var groupConflict = await session.Query<Group>()
.Where(g => g.Email == newEmail && !g.IsDeleted)
.Where(g => g.Email != null && g.Email.ToUpper() == normalizedEmail && !g.IsDeleted)
.AnyAsync();
if (personConflict || groupConflict)
return Error($"Email already in use by another principal: {newEmail}");
Expand Down Expand Up @@ -346,15 +347,18 @@ private static async Task<int> MagicLinkAsync(
/// change leaves <c>mt_doc_principal</c> empty so no user can claim
/// <c>app:admin</c> until the principal projection is replayed.
/// </summary>
private static async Task<int> RebuildProjectionsAsync(IServiceProvider services)
private static async Task<int> RebuildProjectionsAsync(IServiceProvider services, string tenantId)
{
var store = services.GetRequiredService<IDocumentStore>();
var timeout = TimeSpan.FromMinutes(10);

Console.WriteLine("Rebuilding Marten projections...");
Serilog.Log.Warning("Auth: Recovery rebuild-projections initiated");

using var daemon = await store.BuildProjectionDaemonAsync();
// MasterTableTenancy disables Marten's default tenant, so the no-arg
// overload throws DefaultTenantUsageDisabledException — build the daemon
// for the resolved realm's DB explicitly (honors --realm; default system).
using var daemon = await store.BuildProjectionDaemonAsync(tenantId);
await daemon.RebuildProjectionAsync("ViewProjections", timeout, CancellationToken.None);
Console.WriteLine(" OK ViewProjections");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ public static void MapProfileLinkEndpoints(this IEndpointRouteBuilder endpoints,
.Where(l => l.UserId == userId.Value && !l.IsUnlinked && l.Id != link.Id)
.AnyAsync(ct);
var hasPassword = await userManager.HasPasswordAsync(user);
// Passkey count check would require a dedicated store query — approximate
// by trusting local auth fallback when a password is set.
if (!otherLinks && !hasPassword)
var hasPasskey = await writeSession.Query<StoredPasskeyCredential>()
.AnyAsync(c => c.UserId == userId.Value, ct);
if (!otherLinks && !hasPassword && !hasPasskey)
{
return Results.BadRequest(new
{
Expand Down
14 changes: 14 additions & 0 deletions src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ private async Task<ErrorOr<bool>> PerformPermanentEraseAsync(Guid userId, Guid?
// fully erases its PII (no stream to mask). Rides this same batch.
session.Delete<ExternalClaimsStore>(userId);

// WebAuthn passkeys are raw-crypto docs keyed on the user id — drop
// them so a permanently-erased user leaves no orphaned credentials.
// (Recycle-bin / deactivate must NOT do this — that path is reversible.)
session.DeleteWhere<StoredPasskeyCredential>(c => c.UserId == userId);

// Terminal profile change-requests are retained for audit, but their
// payload carries the user's name/email — drop them so no plaintext
// PII survives the erase (the section comment above promised this).
session.DeleteWhere<UserChangeRequest>(r => r.UserId == userId);

// Email-OTP challenge is a 1:1 doc (Id = userId) holding a plaintext
// email — drop it too.
session.Delete<EmailOtpChallenge>(userId);

// External identity links carry Email, DisplayName, and the raw IdP
// claim payload on their OWN streams (keyed by link id). Drop the
// projection doc here; the PII-bearing events are masked + archived
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task<RegisterResponseDto> RegisterAsync(
// STILL return the same success message — no email is sent so
// the original account holder isn't bothered.
var emailTaken = await session.Query<Person>()
.AnyAsync(p => p.Email == normalizedEmail && !p.IsDeleted, ct);
.AnyAsync(p => p.NormalizedEmail == normalizedEmail.ToUpperInvariant() && !p.IsDeleted, ct);
if (emailTaken) return GenericSuccess;

// Username collision IS surfaced — usernames are public-shape
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options)
.Index(x => x.UserId)
.Index(x => x.ExpiresAt);

// WebAuthn/passkey credentials (raw crypto, not event-sourced). One per
// enrolled authenticator; indexed by UserId for the per-user list/login
// lookup and the cascade-delete on permanent erase.
options.Schema.For<StoredPasskeyCredential>()
.Identity(x => x.Id)
.Index(x => x.UserId);

// GDPR: per-user deletion bookkeeping (pending request + masked flag).
// Keyed on the user id so we can simply Load it.
options.Schema.For<UserDeletionState>()
Expand All @@ -107,8 +114,10 @@ public static StoreOptions UseModgudAuthentication(this StoreOptions options)
.Index(x => x.LoginProviderId)
.Index(x => x.IsUnlinked);

// AuthLogDocument lives in the default (public) schema like every other
// auth doc — dropped the gratuitous solo "marten" schema (one schema
// fewer per tenant DB; aligns with AppBase v4).
options.Schema.For<AuthLogDocument>()
.DatabaseSchemaName("marten")
.Identity(x => x.Id)
.Index(x => x.Timestamp);

Expand Down
Loading