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; } }