Files
step3/api/QuestionsApi/Services/PasswordResetServices.cs
T

135 lines
5.9 KiB
C#

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<PasswordResetRequest> _requests =
mongo.Database.GetCollection<PasswordResetRequest>("PasswordResetRequests");
public async Task<PasswordResetRequest?> 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<bool> 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<PasswordResetRequest>.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<PasswordResetRequest>.Update.Inc(request => request.FailedAttempts, 1));
public async Task<bool> TryConsumeAsync(string id)
{
var result = await _requests.UpdateOneAsync(
request => request.Id == id && request.ConsumedUtc == null &&
request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5,
Builders<PasswordResetRequest>.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<PasswordResetRequest>.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow));
public Task DeleteForUserAsync(string userId) =>
_requests.DeleteManyAsync(request => request.UserId == userId);
internal IMongoCollection<PasswordResetRequest> 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
};
}