@@ -400,16 +400,16 @@ public async Task<OneOf<Success, TooManyPasswordResets, AccountNotActivated, Acc
400400 var lowerCaseEmail = email . ToLowerInvariant ( ) ;
401401 var user = await _db . Users
402402 . Where ( x => x . Email == lowerCaseEmail )
403- . Include ( x => x . UserDeactivation )
404403 . Select ( x => new
405404 {
406405 User = x ,
406+ IsDeactivated = x . UserDeactivation != null ,
407407 PasswordResetCount = x . PasswordResets . Count ( y => y . UsedAt == null && y . CreatedAt >= validSince )
408408 } )
409409 . FirstOrDefaultAsync ( ) ;
410410 if ( user is null ) return new NotFound ( ) ;
411411 if ( user . User . ActivatedAt is null ) return new AccountNotActivated ( ) ;
412- if ( user . User . UserDeactivation is not null ) return new AccountDeactivated ( ) ;
412+ if ( user . IsDeactivated ) return new AccountDeactivated ( ) ;
413413 if ( user . PasswordResetCount >= 3 ) return new TooManyPasswordResets ( ) ;
414414
415415 var token = CryptoUtils . RandomAlphaNumericString ( AuthConstants . GeneratedTokenLength ) ;
@@ -436,21 +436,39 @@ public async Task<OneOf<Success, NotFound, AccountNotActivated, AccountDeactivat
436436 var validSince = DateTime . UtcNow - Duration . PasswordResetRequestLifetime ;
437437
438438 var reset = await _db . UserPasswordResets
439- . Include ( x => x . User )
440- . Include ( x => x . User . UserDeactivation )
441- . FirstOrDefaultAsync ( x => x . Id == passwordResetId && x . UsedAt == null && x . CreatedAt >= validSince
442- && x . SecurityStampAtCreate == x . User . SecurityStamp ) ;
439+ . Select ( x => new
440+ {
441+ Reset = x ,
442+ UserActivatedAt = x . User . ActivatedAt ,
443+ IsDeactivated = x . User . UserDeactivation != null ,
444+ UserSecurityStamp = x . User . SecurityStamp
445+ } )
446+ . FirstOrDefaultAsync ( x => x . Reset . Id == passwordResetId && x . Reset . UsedAt == null && x . Reset . CreatedAt >= validSince
447+ && x . Reset . SecurityStampAtCreate == x . UserSecurityStamp ) ;
443448 if ( reset is null ) return new NotFound ( ) ;
444- if ( reset . User . ActivatedAt is null ) return new AccountNotActivated ( ) ;
445- if ( reset . User . UserDeactivation is not null ) return new AccountDeactivated ( ) ;
449+ if ( reset . UserActivatedAt is null ) return new AccountNotActivated ( ) ;
450+ if ( reset . IsDeactivated ) return new AccountDeactivated ( ) ;
446451
447- var result = HashingUtils . VerifyToken ( secret , reset . TokenHash ) ;
452+ var result = HashingUtils . VerifyToken ( secret , reset . Reset . TokenHash ) ;
448453 if ( ! result . Verified ) return new SecretInvalid ( ) ;
449454
450- reset . UsedAt = DateTime . UtcNow ;
451- reset . User . PasswordHash = HashingUtils . HashPassword ( newPassword ) ;
452- reset . User . SecurityStamp = Guid . CreateVersion7 ( ) ; // Rotates the stamp; every other pending reset/email-change for this user is now invalid by predicate.
453- await _db . SaveChangesAsync ( ) ;
455+ // Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot.
456+ // If a sibling reset (or a separate password/email change) completed since the read above,
457+ // the stamp has rotated and the predicate matches zero rows.
458+ var newPasswordHash = HashingUtils . HashPassword ( newPassword ) ;
459+ var newStamp = Guid . CreateVersion7 ( ) ;
460+ var userRows = await _db . Users
461+ . Where ( u => u . Id == reset . Reset . UserId && u . SecurityStamp == reset . Reset . SecurityStampAtCreate )
462+ . ExecuteUpdateAsync ( s => s
463+ . SetProperty ( u => u . PasswordHash , newPasswordHash )
464+ . SetProperty ( u => u . SecurityStamp , newStamp ) ) ;
465+ if ( userRows == 0 ) return new NotFound ( ) ;
466+
467+ var now = DateTime . UtcNow ;
468+ await _db . UserPasswordResets
469+ . Where ( r => r . Id == reset . Reset . Id && r . UsedAt == null )
470+ . ExecuteUpdateAsync ( s => s . SetProperty ( r => r . UsedAt , now ) ) ;
471+
454472 return new Success ( ) ;
455473 }
456474
@@ -537,16 +555,16 @@ public async Task<OneOf<Success, EmailAlreadyInUse, EmailUnchanged, TooManyEmail
537555
538556 var data = await _db . Users
539557 . Where ( x => x . Id == userId )
540- . Include ( x => x . UserDeactivation )
541558 . Select ( x => new
542559 {
543560 User = x ,
561+ IsDeactivated = x . UserDeactivation != null ,
544562 PendingCount = x . EmailChanges . Count ( y => y . UsedAt == null && y . CreatedAt >= validSince )
545563 } )
546564 . FirstOrDefaultAsync ( ) ;
547565 if ( data is null ) return new NotFound ( ) ;
548566 if ( data . User . ActivatedAt is null ) return new AccountNotActivated ( ) ;
549- if ( data . User . UserDeactivation is not null ) return new AccountDeactivated ( ) ;
567+ if ( data . IsDeactivated ) return new AccountDeactivated ( ) ;
550568 if ( string . Equals ( data . User . Email , lowerCaseEmail , StringComparison . Ordinal ) ) return new EmailUnchanged ( ) ;
551569 if ( data . PendingCount >= 3 ) return new TooManyEmailChanges ( ) ;
552570
@@ -594,28 +612,45 @@ public async Task<OneOf<Success, NotFound, EmailAlreadyInUse>> TryVerifyEmailAsy
594612 var validSince = DateTime . UtcNow - Duration . EmailChangeRequestLifetime ;
595613
596614 var change = await _db . UserEmailChanges
597- . Include ( x => x . User ) . ThenInclude ( u => u . UserDeactivation )
598- . FirstOrDefaultAsync ( x => x . TokenHash == hash && x . UsedAt == null && x . CreatedAt >= validSince
615+ . Where ( x => x . TokenHash == hash && x . UsedAt == null && x . CreatedAt >= validSince
599616 && x . SecurityStampAtCreate == x . User . SecurityStamp
600- && x . User . UserDeactivation == null && x . User . ActivatedAt != null , cancellationToken ) ;
617+ && x . User . UserDeactivation == null && x . User . ActivatedAt != null )
618+ . Select ( x => new
619+ {
620+ ChangeId = x . Id ,
621+ UserId = x . UserId ,
622+ x . NewEmail ,
623+ x . SecurityStampAtCreate
624+ } )
625+ . FirstOrDefaultAsync ( cancellationToken ) ;
601626
602627 if ( change is null ) return new NotFound ( ) ;
603628
604- change . UsedAt = DateTime . UtcNow ;
605- change . User . Email = change . NewEmail ;
606- change . User . SecurityStamp = Guid . CreateVersion7 ( ) ; // Rotates the stamp; every other pending reset/email-change for this user is now invalid by predicate.
607-
629+ // Race-safe consume + apply: only updates if SecurityStamp still matches the snapshot, so
630+ // sibling email changes / password resets that completed since the read above cleanly lose.
631+ var newStamp = Guid . CreateVersion7 ( ) ;
608632 try
609633 {
610- await _db . SaveChangesAsync ( cancellationToken ) ;
611- return new Success ( ) ;
634+ var userRows = await _db . Users
635+ . Where ( u => u . Id == change . UserId && u . SecurityStamp == change . SecurityStampAtCreate )
636+ . ExecuteUpdateAsync ( s => s
637+ . SetProperty ( u => u . Email , change . NewEmail )
638+ . SetProperty ( u => u . SecurityStamp , newStamp ) , cancellationToken ) ;
639+ if ( userRows == 0 ) return new NotFound ( ) ;
612640 }
613641 catch ( DbUpdateException ex ) when ( ex . InnerException is PostgresException { SqlState : "23505" } )
614642 {
615643 // Another account claimed this email between request creation and verification.
616644 // The pending row stays as-is (not marked used) so it can expire naturally.
617645 return new EmailAlreadyInUse ( ) ;
618646 }
647+
648+ var now = DateTime . UtcNow ;
649+ await _db . UserEmailChanges
650+ . Where ( c => c . Id == change . ChangeId && c . UsedAt == null )
651+ . ExecuteUpdateAsync ( s => s . SetProperty ( c => c . UsedAt , now ) , cancellationToken ) ;
652+
653+ return new Success ( ) ;
619654 }
620655
621656 private async Task < bool > CheckPassword ( string password , User user )
0 commit comments