Skip to content

Commit 7ea0850

Browse files
committed
address pr review comments
- fix Include() being dropped under Select() projection in CreatePasswordResetFlowAsync and CreateEmailChangeFlowAsync; deactivation was never detected. Project IsDeactivated explicitly. - race-safe complete/verify via ExecuteUpdateAsync gated on the SecurityStamp predicate so two concurrent valid tokens cannot both win and clobber the user's password/email. - add deprecated aliases POST /reset-password and POST /verify-email delegating to the renamed routes. - ChangeEmail: return AccountOAuthOnly (401) for users without a password, instead of the misleading PasswordChangeInvalidPassword. - fix unresolved cref to OpenShock.Common.Constants.Duration in UserPasswordReset and UserEmailChange xmldocs. - clarify User.SecurityStamp doc to make explicit that re-hashing the same password is not a rotation. - update stale EmailVersion comment in MailTests.
1 parent dd046db commit 7ea0850

8 files changed

Lines changed: 123 additions & 40 deletions

File tree

API.IntegrationTests/Tests/MailTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstComp
439439
var firstVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={firstToken}", null);
440440
await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK);
441441

442-
// Second pending request is now invalid: its EmailVersionAtCreate snapshot no longer matches User.EmailVersion.
442+
// Second pending request is now invalid: its SecurityStampAtCreate snapshot no longer matches User.SecurityStamp.
443443
var secondVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={secondToken}", null);
444444
await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
445445

API/Controller/Account/Authenticated/ChangeEmail.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ public sealed partial class AuthenticatedAccountController
1919
[Consumes(MediaTypeNames.Application.Json)]
2020
[ProducesResponseType(StatusCodes.Status200OK)]
2121
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // EmailChangeUnchanged
22+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // AccountOAuthOnly
2223
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword
2324
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse
2425
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany
2526
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
2627
{
27-
if (string.IsNullOrEmpty(CurrentUser.PasswordHash) || !HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified)
28+
if (string.IsNullOrEmpty(CurrentUser.PasswordHash))
29+
{
30+
return Problem(AccountError.AccountOAuthOnly);
31+
}
32+
33+
if (!HashingUtils.VerifyPassword(body.CurrentPassword, CurrentUser.PasswordHash).Verified)
2834
{
2935
return Problem(AccountError.PasswordChangeInvalidPassword);
3036
}

API/Controller/Account/PasswordResetInitiateV2.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Net;
1+
using System;
2+
using System.Net;
23
using System.Net.Mime;
34
using Microsoft.AspNetCore.Mvc;
45
using Asp.Versioning;
@@ -23,7 +24,24 @@ public sealed partial class AccountController
2324
[ProducesResponseType(StatusCodes.Status200OK)]
2425
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
2526
[MapToApiVersion("2")]
26-
public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
27+
public Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
28+
=> PasswordResetInitiate(body, turnstileService, cancellationToken);
29+
30+
/// <summary>
31+
/// Initiate a password reset. Deprecated: use POST /password-reset instead.
32+
/// </summary>
33+
/// <response code="200">Password reset email sent if the email is associated to an registered account</response>
34+
[Obsolete("Use POST /password-reset instead.")]
35+
[HttpPost("reset-password")]
36+
[EnableRateLimiting("auth")]
37+
[Consumes(MediaTypeNames.Application.Json)]
38+
[ProducesResponseType(StatusCodes.Status200OK)]
39+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)]
40+
[MapToApiVersion("2")]
41+
public Task<IActionResult> PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
42+
=> PasswordResetInitiate(body, turnstileService, cancellationToken);
43+
44+
private async Task<IActionResult> PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken)
2745
{
2846
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken);
2947
if (!turnStile.IsT0)
@@ -34,9 +52,9 @@ public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordRese
3452

3553
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
3654
}
37-
55+
3856
await _accountService.CreatePasswordResetFlowAsync(body.Email);
39-
57+
4058
return Ok();
4159
}
4260
}

API/Controller/Account/VerifyEmail.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Net.Mime;
1+
using System;
2+
using System.Net.Mime;
23
using Microsoft.AspNetCore.Mvc;
34
using Asp.Versioning;
45
using OpenShock.Common.Errors;
@@ -19,7 +20,25 @@ public sealed partial class AccountController
1920
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)]
2021
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
2122
[MapToApiVersion("1")]
22-
public async Task<IActionResult> EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken)
23+
public Task<IActionResult> EmailVerify([FromQuery(Name = "token")] string token, CancellationToken cancellationToken)
24+
=> VerifyPendingEmailChange(token, cancellationToken);
25+
26+
/// <summary>
27+
/// Verify a pending email change. Deprecated: use POST /email-change/verify instead.
28+
/// </summary>
29+
/// <response code="200">Email change verified and applied</response>
30+
/// <response code="400">Token is invalid, already used, or the request has expired</response>
31+
/// <response code="409">The new email address was claimed by another account before verification completed</response>
32+
[Obsolete("Use POST /email-change/verify instead.")]
33+
[HttpPost("verify-email")]
34+
[ProducesResponseType(StatusCodes.Status200OK)]
35+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)]
36+
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)]
37+
[MapToApiVersion("1")]
38+
public Task<IActionResult> EmailVerifyLegacy([FromQuery(Name = "token")] string token, CancellationToken cancellationToken)
39+
=> VerifyPendingEmailChange(token, cancellationToken);
40+
41+
private async Task<IActionResult> VerifyPendingEmailChange(string token, CancellationToken cancellationToken)
2342
{
2443
var result = await _accountService.TryVerifyEmailAsync(token, cancellationToken);
2544

API/Services/Account/AccountService.cs

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

Common/OpenShockDb/User.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ public sealed class User
1313
public string? PasswordHash { get; set; }
1414

1515
/// <summary>
16-
/// Opaque value rotated whenever a security-sensitive field on this user changes
17-
/// (currently <see cref="PasswordHash"/> and <see cref="Email"/>). Snapshotted onto pending
18-
/// password resets and email changes so that any rotation silently invalidates every
19-
/// outstanding request for this user, regardless of which code path applied the change.
16+
/// Opaque value rotated whenever the user's password or email <em>value</em> changes
17+
/// (re-hashing the same password with a stronger algorithm does not rotate it).
18+
/// Snapshotted onto pending password resets and email changes so that any rotation
19+
/// silently invalidates every outstanding request for this user, regardless of which
20+
/// code path applied the change.
2021
/// </summary>
2122
public Guid SecurityStamp { get; set; }
2223

Common/OpenShockDb/UserEmailChange.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace OpenShock.Common.OpenShockDb;
1+
using OpenShock.Common.Constants;
2+
3+
namespace OpenShock.Common.OpenShockDb;
24

35
/// <summary>
46
/// A pending or completed email-change request. The row is created when an authenticated user
@@ -50,7 +52,7 @@ public sealed class UserEmailChange
5052
public DateTime? UsedAt { get; set; }
5153

5254
/// <summary>
53-
/// When the request was created. Combined with <see cref="Constants.Duration.EmailChangeRequestLifetime"/>
55+
/// When the request was created. Combined with <see cref="Duration.EmailChangeRequestLifetime"/>
5456
/// to enforce the link's expiry.
5557
/// </summary>
5658
public DateTime CreatedAt { get; set; }

Common/OpenShockDb/UserPasswordReset.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace OpenShock.Common.OpenShockDb;
1+
using OpenShock.Common.Constants;
2+
3+
namespace OpenShock.Common.OpenShockDb;
24

35
/// <summary>
46
/// A pending or completed password reset request, created when a user (or someone with knowledge
@@ -37,7 +39,7 @@ public sealed class UserPasswordReset
3739
public DateTime? UsedAt { get; set; }
3840

3941
/// <summary>
40-
/// When the reset was created. Combined with <see cref="Constants.Duration.PasswordResetRequestLifetime"/>
42+
/// When the reset was created. Combined with <see cref="Duration.PasswordResetRequestLifetime"/>
4143
/// to enforce the link's expiry.
4244
/// </summary>
4345
public DateTime CreatedAt { get; set; }

0 commit comments

Comments
 (0)