135 lines
5.9 KiB
C#
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
|
|
};
|
|
}
|