using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; using MongoDB.Driver; using QuestionsApi.Models; using System.Security.Cryptography; using System.Text; namespace QuestionsApi.Services; public sealed class PasswordResetCodeService { private readonly byte[]? _key; public PasswordResetCodeService(IConfiguration configuration) { var key = configuration["PasswordReset:HmacKey"]; if (!string.IsNullOrWhiteSpace(key) && Encoding.UTF8.GetByteCount(key) >= 32) _key = Encoding.UTF8.GetBytes(key); } public bool IsConfigured => _key is not null; public string GenerateCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); public string Hash(string requestId, string code) { if (_key is null) throw new InvalidOperationException("PasswordReset:HmacKey must contain at least 32 bytes."); using var hmac = new HMACSHA256(_key); return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{requestId}:{code}"))); } public bool Verify(string requestId, string code, string expectedHash) { if (string.IsNullOrWhiteSpace(expectedHash)) return false; var actual = Convert.FromHexString(Hash(requestId, code)); byte[] expected; try { expected = Convert.FromHexString(expectedHash); } catch (FormatException) { return false; } return actual.Length == expected.Length && CryptographicOperations.FixedTimeEquals(actual, expected); } } public sealed class PasswordResetStore(MongoDatabaseProvider mongo) { private readonly IMongoCollection _requests = mongo.Database.GetCollection("PasswordResetRequests"); public async Task LatestActiveAsync(string userId) => await _requests.Find(request => request.UserId == userId && request.ConsumedUtc == null && request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5) .SortByDescending(request => request.CreatedUtc).FirstOrDefaultAsync(); public Task IssuedRecentlyAsync(string userId, DateTime sinceUtc) => _requests.Find(request => request.UserId == userId && request.CreatedUtc >= sinceUtc).AnyAsync(); public async Task CreateAsync(PasswordResetRequest request) { await _requests.UpdateManyAsync( value => value.UserId == request.UserId && value.ConsumedUtc == null, Builders.Update.Set(value => value.ConsumedUtc, DateTime.UtcNow)); await _requests.InsertOneAsync(request); } public Task RecordFailureAsync(string id) => _requests.UpdateOneAsync( request => request.Id == id && request.ConsumedUtc == null, Builders.Update.Inc(request => request.FailedAttempts, 1)); public async Task TryConsumeAsync(string id) { var result = await _requests.UpdateOneAsync( request => request.Id == id && request.ConsumedUtc == null && request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5, Builders.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow)); return result.ModifiedCount == 1; } public Task ConsumeForUserAsync(string userId) => _requests.UpdateManyAsync( request => request.UserId == userId && request.ConsumedUtc == null, Builders.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow)); public Task DeleteForUserAsync(string userId) => _requests.DeleteManyAsync(request => request.UserId == userId); internal IMongoCollection Collection => _requests; } public interface IPasswordResetEmailSender { Task SendAsync(string destination, string username, string code, CancellationToken cancellationToken); } public sealed class SmtpPasswordResetEmailSender(IConfiguration configuration) : IPasswordResetEmailSender { public async Task SendAsync(string destination, string username, string code, CancellationToken cancellationToken) { var host = Required("Smtp:Host"); var fromAddress = Required("Smtp:FromAddress"); var fromName = configuration["Smtp:FromName"] ?? "RanaZe: Step 3 Trainer"; var message = new MimeMessage(); message.From.Add(new MailboxAddress(fromName, fromAddress)); message.To.Add(MailboxAddress.Parse(destination)); message.Subject = "Your RanaZe password reset code"; message.Body = new TextPart("plain") { Text = $"Hello {username},\n\nYour RanaZe: Step 3 Trainer password reset code is {code}. " + "It expires in 10 minutes and can be used once.\n\n" + "If you did not request this code, you can ignore this email." }; using var client = new SmtpClient(); client.Timeout = 15_000; await client.ConnectAsync(host, configuration.GetValue("Smtp:Port", 587), SecurityOption(), cancellationToken); var usernameValue = configuration["Smtp:Username"]; if (!string.IsNullOrWhiteSpace(usernameValue)) await client.AuthenticateAsync(usernameValue, Required("Smtp:Password"), cancellationToken); await client.SendAsync(message, cancellationToken); await client.DisconnectAsync(true, cancellationToken); } private string Required(string key) => configuration[key] ?? throw new InvalidOperationException($"{key} is required."); private SecureSocketOptions SecurityOption() => (configuration["Smtp:Security"] ?? "StartTls").Trim().ToLowerInvariant() switch { "ssl" or "sslontconnect" => SecureSocketOptions.SslOnConnect, "none" => SecureSocketOptions.None, "auto" => SecureSocketOptions.Auto, _ => SecureSocketOptions.StartTls }; }