using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
using BC = BCrypt.Net.BCrypt;
using Timer = System.Timers.Timer;
namespace OliverBooth.Services;
///
/// Represents an implementation of .
///
internal sealed class UserService : BackgroundService, IUserService
{
private static readonly RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create();
private readonly IDbContextFactory _dbContextFactory;
private readonly ConcurrentDictionary _userCache = new();
private readonly ConcurrentDictionary _tokenCache = new();
private readonly Timer _tokenClearTimer = new();
///
/// Initializes a new instance of the class.
///
///
/// The used to create a .
///
public UserService(IDbContextFactory dbContextFactory)
{
_dbContextFactory = dbContextFactory;
_tokenClearTimer.Interval = TimeSpan.FromMinutes(5).TotalMilliseconds;
_tokenClearTimer.Elapsed += (_, _) => ClearExpiredTokens();
}
///
public void ClearExpiredTokens()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var keysToRemove = new string[_tokenCache.Count];
var insertionIndex = 0;
foreach (var (key, token) in _tokenCache)
{
if (token.Expires <= now)
{
keysToRemove[insertionIndex++] = key;
}
}
for (var index = 0; index < insertionIndex; index++)
{
_tokenCache.TryRemove(keysToRemove[index], out _);
}
}
///
public void ClearTokens()
{
_tokenCache.Clear();
}
///
public IMfaToken CreateMfaToken(IUser user)
{
if (user is null)
{
throw new ArgumentNullException(nameof(user));
}
DateTimeOffset now = DateTimeOffset.UtcNow;
var token = new MfaToken
{
Token = CreateToken(),
User = user,
Attempts = 0,
Created = now,
Expires = now + TimeSpan.FromMinutes(5)
};
_tokenCache[token.Token] = token;
return token;
// while we do want a string, BitConvert.ToString requires a heap byte array
// which is just very not pog. so this method behaves the same but uses a Span
// while still returning a string necessary for the IMfaToken model
static string CreateToken()
{
ReadOnlySpan hexChars = "0123456789ABCDEF";
Span chars = stackalloc char[128];
Span buffer = stackalloc byte[64];
RandomNumberGenerator.GetBytes(buffer);
for (var index = 0; index < buffer.Length; index++)
{
int byteValue = buffer[index];
chars[index * 2] = hexChars[byteValue >> 4];
chars[index * 2 + 1] = hexChars[byteValue & 0xF];
}
return chars.ToString();
}
}
///
public void DeleteToken(string token)
{
if (token is null)
{
throw new ArgumentNullException(nameof(token));
}
_tokenCache.TryRemove(token, out _);
}
///
public bool TryGetUser(Guid id, [NotNullWhen(true)] out IUser? user)
{
if (_userCache.TryGetValue(id, out user))
{
return true;
}
using WebContext context = _dbContextFactory.CreateDbContext();
user = context.Users.Find(id);
if (user is not null)
{
_userCache.TryAdd(id, user);
}
return user is not null;
}
///
public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user)
{
using WebContext context = _dbContextFactory.CreateDbContext();
user = context.Users.FirstOrDefault(u => u.EmailAddress == email);
return user is not null && BC.Verify(password, ((User)user).Password);
}
///
public MfaRequestResult VerifyMfaRequest(string token, string totp, out IUser? user)
{
if (token is null)
{
throw new ArgumentNullException(nameof(token));
}
if (totp is null)
{
throw new ArgumentNullException(nameof(totp));
}
user = null;
if (!_tokenCache.TryGetValue(token, out MfaToken? mfaToken))
{
return MfaRequestResult.TokenExpired;
}
if (!mfaToken.User.TestTotp(totp))
{
mfaToken.Attempts++;
if (mfaToken.Attempts == 4)
{
return MfaRequestResult.TooManyAttempts;
}
return MfaRequestResult.InvalidTotp;
}
user = mfaToken.User;
return MfaRequestResult.Success;
}
///
public override Task StopAsync(CancellationToken cancellationToken)
{
_tokenClearTimer.Stop();
return base.StopAsync(cancellationToken);
}
///
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
ClearTokens();
_tokenClearTimer.Start();
return Task.CompletedTask;
}
}