diff --git a/OliverBooth/Controllers/Api/v1/AuthenticationController.cs b/OliverBooth/Controllers/Api/v1/AuthenticationController.cs index 98d7c20..3b4b7da 100644 --- a/OliverBooth/Controllers/Api/v1/AuthenticationController.cs +++ b/OliverBooth/Controllers/Api/v1/AuthenticationController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Asp.Versioning; using Microsoft.AspNetCore.Mvc; using OliverBooth.Data.Web; @@ -33,6 +34,44 @@ public sealed class AuthenticationController : ControllerBase _userService = userService; } + /// + /// Authorizes a multi-factor login request using the specified token and TOTP. + /// + /// The token. + /// The time-based one-time password. + /// The result of the authentication process. + public IActionResult DoMultiFactor([FromForm(Name = "token")] string token, [FromForm(Name = "totp")] string totp) + { + string epName = nameof(DoMultiFactor); + if (Request.HttpContext.Connection.RemoteIpAddress is not { } ip) + { + _logger.LogWarning("Endpoint {Name} reached with no remote IP!", epName); + return BadRequest(); + } + + MfaRequestResult result = _userService.VerifyMfaRequest(token, totp, out IUser? user); + switch (result) + { + case MfaRequestResult.InvalidTotp: + return RedirectToPage("/admin/multifactorstep", new { token, result = (int)result }); + + case MfaRequestResult.TokenExpired: + return RedirectToPage("/admin/login", new { result = (int)result }); + + case MfaRequestResult.TooManyAttempts: + return RedirectToPage("/admin/login", new { result = (int)result }); + } + + Debug.Assert(user is not null); + + _userService.DeleteToken(token); + + ISession session = _sessionService.CreateSession(Request, user); + _sessionService.SaveSessionCookie(Response, session); + _logger.LogInformation("MFA request from {Host} with login {Login} succeeded", ip, user.EmailAddress); + return RedirectToPage("/admin/index"); + } + /// /// Authorizes a login request using the specified credentials. /// @@ -75,6 +114,14 @@ public sealed class AuthenticationController : ControllerBase return redirectResult; } + if (!string.IsNullOrWhiteSpace(user.Totp)) + { + // mfa required + _logger.LogInformation("Login attempt from {Host} with login {Login} requires MFA", ip, emailAddress); + IMfaToken token = _userService.CreateMfaToken(user); + return RedirectToPage("/admin/multifactorstep", new { token = token.Token }); + } + ISession session = _sessionService.CreateSession(Request, user); _sessionService.SaveSessionCookie(Response, session); _logger.LogInformation("Login attempt from {Host} with login {Login} succeeded", ip, emailAddress); diff --git a/OliverBooth/Data/Web/IMfaToken.cs b/OliverBooth/Data/Web/IMfaToken.cs new file mode 100644 index 0000000..9c61e5d --- /dev/null +++ b/OliverBooth/Data/Web/IMfaToken.cs @@ -0,0 +1,37 @@ +namespace OliverBooth.Data.Web; + +/// +/// Represents a temporary token used to correlate MFA attempts with the user. +/// +public interface IMfaToken +{ + /// + /// Gets a value indicating the number of attempts made with this token. + /// + /// The number of attempts. + int Attempts { get; } + + /// + /// Gets the date and time at which this token was created. + /// + /// The creation timestamp. + DateTimeOffset Created { get; } + + /// + /// Gets the date and time at which this token expires. + /// + /// The expiration timestamp. + DateTimeOffset Expires { get; } + + /// + /// Gets the 512-bit token for MFA. + /// + /// The temporary MFA token. + string Token { get; } + + /// + /// Gets the user to whom this token is associated. + /// + /// The user. + IUser User { get; } +} \ No newline at end of file diff --git a/OliverBooth/Data/Web/IUser.cs b/OliverBooth/Data/Web/IUser.cs index 83f4aa8..fced72a 100644 --- a/OliverBooth/Data/Web/IUser.cs +++ b/OliverBooth/Data/Web/IUser.cs @@ -81,4 +81,15 @@ public interface IUser /// . /// bool TestCredentials(string password); + + /// + /// Tests the specified TOTP with the user's current TOTP. + /// + /// The TOTP to test. + /// + /// if the specified time-based one-time password matches that of the user; otherwise, + /// . + /// + /// is . + bool TestTotp(string value); } diff --git a/OliverBooth/Data/Web/MfaRequestResult.cs b/OliverBooth/Data/Web/MfaRequestResult.cs new file mode 100644 index 0000000..d92a793 --- /dev/null +++ b/OliverBooth/Data/Web/MfaRequestResult.cs @@ -0,0 +1,29 @@ +using OliverBooth.Services; + +namespace OliverBooth.Data.Web; + +/// +/// An enumeration of possible results for . +/// +public enum MfaRequestResult +{ + /// + /// The request was successful. + /// + Success, + + /// + /// The wrong code was entered. + /// + InvalidTotp, + + /// + /// The MFA token has expired. + /// + TokenExpired, + + /// + /// Too many attempts were made by the user. + /// + TooManyAttempts, +} diff --git a/OliverBooth/Data/Web/MfaToken.cs b/OliverBooth/Data/Web/MfaToken.cs new file mode 100644 index 0000000..b2a9951 --- /dev/null +++ b/OliverBooth/Data/Web/MfaToken.cs @@ -0,0 +1,19 @@ +namespace OliverBooth.Data.Web; + +internal sealed class MfaToken : IMfaToken +{ + /// + public int Attempts { get; set; } + + /// + public DateTimeOffset Created { get; set; } + + /// + public DateTimeOffset Expires { get; set; } + + /// + public string Token { get; set; } = string.Empty; + + /// + public IUser User { get; set; } = null!; +} diff --git a/OliverBooth/Data/Web/User.cs b/OliverBooth/Data/Web/User.cs index c8eaa66..67d6efe 100644 --- a/OliverBooth/Data/Web/User.cs +++ b/OliverBooth/Data/Web/User.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using Cysharp.Text; using OliverBooth.Data.Blog; +using OtpNet; namespace OliverBooth.Data.Web; @@ -97,4 +98,12 @@ internal sealed class User : IUser, IBlogAuthor { return false; } + + /// + public bool TestTotp(string value) + { + byte[]? key = Base32Encoding.ToBytes(Totp); + var totp = new Totp(key); + return totp.VerifyTotp(value, out _, VerificationWindow.RfcSpecifiedNetworkDelay); + } } diff --git a/OliverBooth/OliverBooth.csproj b/OliverBooth/OliverBooth.csproj index 8404634..e387562 100644 --- a/OliverBooth/OliverBooth.csproj +++ b/OliverBooth/OliverBooth.csproj @@ -21,6 +21,7 @@ + diff --git a/OliverBooth/Pages/Admin/MultiFactorStep.cshtml b/OliverBooth/Pages/Admin/MultiFactorStep.cshtml new file mode 100644 index 0000000..e41a58b --- /dev/null +++ b/OliverBooth/Pages/Admin/MultiFactorStep.cshtml @@ -0,0 +1,35 @@ +@page "/admin/login/mfa" +@using OliverBooth.Data.Web +@model OliverBooth.Pages.Admin.MultiFactorStep + +@{ + ViewData["Title"] = "2FA Step"; +} + +
+ @if (Model.Result.HasValue) + { + var result = (MfaRequestResult)Model.Result.Value; + switch (result) + { + case MfaRequestResult.InvalidTotp: +
+

Error

+

The code you entered is invalid.

+
+ break; + } + } + +
+

Please enter 2FA code

+ +
+ + + +
+ + +
+
\ No newline at end of file diff --git a/OliverBooth/Pages/Admin/MultiFactorStep.cshtml.cs b/OliverBooth/Pages/Admin/MultiFactorStep.cshtml.cs new file mode 100644 index 0000000..8ae4725 --- /dev/null +++ b/OliverBooth/Pages/Admin/MultiFactorStep.cshtml.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using OliverBooth.Services; + +namespace OliverBooth.Pages.Admin; + +public class MultiFactorStep : PageModel +{ + private readonly ISessionService _sessionService; + + public MultiFactorStep(ISessionService sessionService) + { + _sessionService = sessionService; + } + + public string Token { get; private set; } = string.Empty; + + public int? Result { get; private set; } + + public IActionResult OnGet([FromQuery(Name = "token")] string token, + [FromQuery(Name = "result")] int? result = null) + { + Token = token; + Result = result; + return _sessionService.TryGetCurrentUser(Request, Response, out _) ? RedirectToPage("/admin/index") : Page(); + } +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index a122bab..fa5b93a 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -46,10 +46,10 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddControllersWithViews(); diff --git a/OliverBooth/Services/IUserService.cs b/OliverBooth/Services/IUserService.cs index d52b65e..336b220 100644 --- a/OliverBooth/Services/IUserService.cs +++ b/OliverBooth/Services/IUserService.cs @@ -8,6 +8,31 @@ namespace OliverBooth.Services; /// public interface IUserService { + /// + /// Clears all expired tokens. + /// + void ClearExpiredTokens(); + + /// + /// Clears all tokens. + /// + void ClearTokens(); + + /// + /// Creates a temporary MFA token for the specified user. + /// + /// The user for whom to create the token. + /// The newly-created token. + /// is . + IMfaToken CreateMfaToken(IUser user); + + /// + /// Deletes the specified token. + /// + /// The token to delete. + /// is . + void DeleteToken(string token); + /// /// Attempts to find a user with the specified ID. /// @@ -33,5 +58,24 @@ public interface IUserService /// /// if the login credentials are valid; otherwise, . /// - public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user); + bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user); + + /// + /// Verifies the MFA request for the specified user. + /// + /// The MFA token. + /// The user-provided TOTP. + /// + /// When this method returns, contains the user associated with the specified token, if the verification was + /// successful; otherwise, . + /// + /// + /// An representing the result of the request. + /// + /// + /// is . + /// -or- + /// is . + /// + MfaRequestResult VerifyMfaRequest(string token, string totp, out IUser? user); } diff --git a/OliverBooth/Services/UserService.cs b/OliverBooth/Services/UserService.cs index 932405a..dc7a5ef 100644 --- a/OliverBooth/Services/UserService.cs +++ b/OliverBooth/Services/UserService.cs @@ -1,18 +1,23 @@ 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 : IUserService +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. @@ -23,6 +28,90 @@ internal sealed class UserService : IUserService 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 _); } /// @@ -51,4 +140,54 @@ internal sealed class UserService : IUserService 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; + } }