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