Merge authentication into Questions API
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user