shoc-backend/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs
Alexandre Brandizzi 77a10e38ca fix(auth): cap reset abuse per account, key code hashes, send reset email off the request path
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and
  an account gets 10 failed code checks a day across every code it is sent,
  so new client addresses and new codes no longer buy more guesses. Refused
  requests answer exactly like accepted ones.
- The reset email is queued to a background sender, and unregistered
  addresses store a row no code can match, so both paths do the same work
  and return without waiting on the mail provider. Each request also clears
  expired codes.
- Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT
  signing secret; rows in the previous unkeyed format stop matching.
- Email and code are read only from the JSON body.
2026-09-25 13:00:03 -03:00

91 lines
3.8 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, 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<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();
}
}