shoc-backend/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs
Alexandre Brandizzi bcc9b6d7a2 fix(auth): invalidate every pending reset code for an email together
Two concurrent first requests can leave two pending codes for one email.
Exhausting or using one now deletes all of them, so a sibling code cannot
become live afterwards.
2026-09-25 12:31:17 -03:00

80 lines
3.2 KiB
C#

using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class ForgetPasswordDataService : IForgetPasswordDataService
{
private readonly ApplicationDbContext _context;
public ForgetPasswordDataService(ApplicationDbContext context)
{
_context = context;
}
public async Task ReplaceCodeAsync(string email, string userId, string codeHash, string codeSalt, DateTime expiresAtUtc, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
var existing = await _context.ForgetPasswordCodes
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.ToListAsync(cancellationToken);
_context.ForgetPasswordCodes.RemoveRange(existing);
_context.ForgetPasswordCodes.Add(new ForgetPasswordCode
{
Email = email,
UserId = userId,
Code = string.Empty,
CodeHash = codeHash,
CodeSalt = codeSalt,
ExpiresAtUtc = expiresAtUtc,
FailedAttempts = 0
});
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
return await _context.ForgetPasswordCodes
.AsNoTracking()
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
.OrderByDescending(u => u.Id)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<bool> TryConsumeAttemptAsync(int id, int maxAttempts, DateTime nowUtc, CancellationToken cancellationToken)
{
// A single conditional UPDATE, so concurrent checks can never consume
// more than maxAttempts between them.
var updated = await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts < maxAttempts && u.ExpiresAtUtc > nowUtc)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts + 1),
cancellationToken);
return updated == 1;
}
public async Task RefundAttemptAsync(int id, CancellationToken cancellationToken)
{
await _context.ForgetPasswordCodes
.Where(u => u.Id == id && u.FailedAttempts > 0)
.ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.FailedAttempts, u => u.FailedAttempts - 1),
cancellationToken);
}
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
{
var normalizedEmail = Normalize(email);
await _context.ForgetPasswordCodes
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
.ExecuteDeleteAsync(cancellationToken);
}
private static string Normalize(string email) => email.ToLower().Trim();
}
}