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, DateTime nowUtc, CancellationToken cancellationToken) { var normalizedEmail = Normalize(email); await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); // Every request also clears expired codes of any email, which keeps the // table bounded and gives each request the same database work. await _context.ForgetPasswordCodes .Where(u => u.Email.ToLower().Trim() == normalizedEmail || u.ExpiresAtUtc <= nowUtc) .ExecuteDeleteAsync(cancellationToken); _context.ForgetPasswordCodes.Add(new ForgetPasswordCode { Email = email, UserId = userId, Code = string.Empty, CodeHash = codeHash, CodeSalt = codeSalt, ExpiresAtUtc = expiresAtUtc, FailedAttempts = 0 }); await _context.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); } public async Task PurgeExpiredAsync(DateTime nowUtc, CancellationToken cancellationToken) { await _context.ForgetPasswordCodes .Where(u => u.ExpiresAtUtc <= nowUtc) .ExecuteDeleteAsync(cancellationToken); } public async Task 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 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); } public async Task RemoveIssuedThroughAsync(string email, int throughId, CancellationToken cancellationToken) { var normalizedEmail = Normalize(email); await _context.ForgetPasswordCodes .Where(code => code.Email.ToLower().Trim() == normalizedEmail && code.Id <= throughId) .ExecuteDeleteAsync(cancellationToken); } private static string Normalize(string email) => email.ToLower().Trim(); } }