shoc-backend/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs
Alexandre Brandizzi c841e130be fix(auth): harden password reset codes against guessing and email enumeration
Forgot Password answers every address the same way and emails a code only
to an active account. Codes are stored as salted SHA-256 hashes, expire 15
minutes after issue, are replaced by a newer request, and are checked only
against the email they were issued to. Five failed checks delete the code;
attempts are reserved with one conditional UPDATE so concurrent guesses
cannot exceed the budget. VerificationCode requires the email, and email and
code are accepted in the JSON body so they stay out of URLs.

The three anonymous endpoints are rate limited to 10 requests per 15
minutes per client IP. Forwarded headers are trusted only through loopback
and private hops, since the API sits behind the EB load balancer and nginx.
The migration adds hash, salt, expiry and attempt columns and deletes the
old plaintext rows.
2026-09-25 12:21:29 -03:00

87 lines
3.4 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 RemoveAsync(int id, CancellationToken cancellationToken)
{
await _context.ForgetPasswordCodes
.Where(u => u.Id == id)
.ExecuteDeleteAsync(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();
}
}