feat: implement MFA for admin login

This commit is contained in:
Oliver Booth 2024-02-25 17:21:29 +00:00
parent d38167bb97
commit faf3c4c3a8
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
12 changed files with 401 additions and 3 deletions

View File

@ -1,3 +1,4 @@
using System.Diagnostics;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
@ -33,6 +34,44 @@ public sealed class AuthenticationController : ControllerBase
_userService = userService; _userService = userService;
} }
/// <summary>
/// Authorizes a multi-factor login request using the specified token and TOTP.
/// </summary>
/// <param name="token">The token.</param>
/// <param name="totp">The time-based one-time password.</param>
/// <returns>The result of the authentication process.</returns>
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");
}
/// <summary> /// <summary>
/// Authorizes a login request using the specified credentials. /// Authorizes a login request using the specified credentials.
/// </summary> /// </summary>
@ -75,6 +114,14 @@ public sealed class AuthenticationController : ControllerBase
return redirectResult; 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); ISession session = _sessionService.CreateSession(Request, user);
_sessionService.SaveSessionCookie(Response, session); _sessionService.SaveSessionCookie(Response, session);
_logger.LogInformation("Login attempt from {Host} with login {Login} succeeded", ip, emailAddress); _logger.LogInformation("Login attempt from {Host} with login {Login} succeeded", ip, emailAddress);

View File

@ -0,0 +1,37 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a temporary token used to correlate MFA attempts with the user.
/// </summary>
public interface IMfaToken
{
/// <summary>
/// Gets a value indicating the number of attempts made with this token.
/// </summary>
/// <value>The number of attempts.</value>
int Attempts { get; }
/// <summary>
/// Gets the date and time at which this token was created.
/// </summary>
/// <value>The creation timestamp.</value>
DateTimeOffset Created { get; }
/// <summary>
/// Gets the date and time at which this token expires.
/// </summary>
/// <value>The expiration timestamp.</value>
DateTimeOffset Expires { get; }
/// <summary>
/// Gets the 512-bit token for MFA.
/// </summary>
/// <value>The temporary MFA token.</value>
string Token { get; }
/// <summary>
/// Gets the user to whom this token is associated.
/// </summary>
/// <value>The user.</value>
IUser User { get; }
}

View File

@ -81,4 +81,15 @@ public interface IUser
/// <see langword="false" />. /// <see langword="false" />.
/// </returns> /// </returns>
bool TestCredentials(string password); bool TestCredentials(string password);
/// <summary>
/// Tests the specified TOTP with the user's current TOTP.
/// </summary>
/// <param name="value">The TOTP to test.</param>
/// <returns>
/// <see langword="true" /> if the specified time-based one-time password matches that of the user; otherwise,
/// <see langword="false" />.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="value" /> is <see langword="null" />.</exception>
bool TestTotp(string value);
} }

View File

@ -0,0 +1,29 @@
using OliverBooth.Services;
namespace OliverBooth.Data.Web;
/// <summary>
/// An enumeration of possible results for <see cref="IUserService.VerifyMfaRequest" />.
/// </summary>
public enum MfaRequestResult
{
/// <summary>
/// The request was successful.
/// </summary>
Success,
/// <summary>
/// The wrong code was entered.
/// </summary>
InvalidTotp,
/// <summary>
/// The MFA token has expired.
/// </summary>
TokenExpired,
/// <summary>
/// Too many attempts were made by the user.
/// </summary>
TooManyAttempts,
}

View File

@ -0,0 +1,19 @@
namespace OliverBooth.Data.Web;
internal sealed class MfaToken : IMfaToken
{
/// <inheritdoc />
public int Attempts { get; set; }
/// <inheritdoc />
public DateTimeOffset Created { get; set; }
/// <inheritdoc />
public DateTimeOffset Expires { get; set; }
/// <inheritdoc />
public string Token { get; set; } = string.Empty;
/// <inheritdoc />
public IUser User { get; set; } = null!;
}

View File

@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Cysharp.Text; using Cysharp.Text;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
using OtpNet;
namespace OliverBooth.Data.Web; namespace OliverBooth.Data.Web;
@ -97,4 +98,12 @@ internal sealed class User : IUser, IBlogAuthor
{ {
return false; return false;
} }
/// <inheritdoc />
public bool TestTotp(string value)
{
byte[]? key = Base32Encoding.ToBytes(Totp);
var totp = new Totp(key);
return totp.VerifyTotp(value, out _, VerificationWindow.RfcSpecifiedNetworkDelay);
}
} }

View File

@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.0"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.0"/>
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.0"/> <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.0"/>
<PackageReference Include="NetBarcode" Version="1.7.0"/> <PackageReference Include="NetBarcode" Version="1.7.0"/>
<PackageReference Include="Otp.NET" Version="1.3.0"/>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0"/>
<PackageReference Include="Serilog" Version="3.1.1"/> <PackageReference Include="Serilog" Version="3.1.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0"/> <PackageReference Include="Serilog.AspNetCore" Version="8.0.0"/>

View File

@ -0,0 +1,35 @@
@page "/admin/login/mfa"
@using OliverBooth.Data.Web
@model OliverBooth.Pages.Admin.MultiFactorStep
@{
ViewData["Title"] = "2FA Step";
}
<div class="m-auto" style="max-width: 330px; padding: 1rem;">
@if (Model.Result.HasValue)
{
var result = (MfaRequestResult)Model.Result.Value;
switch (result)
{
case MfaRequestResult.InvalidTotp:
<div class="alert alert-danger">
<p class="lead">Error</p>
<p>The code you entered is invalid.</p>
</div>
break;
}
}
<form method="post" asp-controller="Authentication" asp-action="DoMultiFactor" asp-route-version="1">
<h1 class="h3 mb-3 fw-normal">Please enter 2FA code</h1>
<div class="form-floating">
<input type="hidden" name="token" value="@Model.Token">
<input type="text" class="form-control" id="totp" name="totp" placeholder="e.g. 123456">
<label for="totp">2FA code</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Verify</button>
</form>
</div>

View File

@ -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();
}
}

View File

@ -46,10 +46,10 @@ builder.Services.AddHttpClient();
builder.Services.AddSingleton<IContactService, ContactService>(); builder.Services.AddSingleton<IContactService, ContactService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>(); builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>(); builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IUserService, UserService>();
builder.Services.AddSingleton<IProjectService, ProjectService>(); builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddSingleton<IMastodonService, MastodonService>(); builder.Services.AddSingleton<IMastodonService, MastodonService>();
builder.Services.AddSingleton<IReadingListService, ReadingListService>(); builder.Services.AddSingleton<IReadingListService, ReadingListService>();
builder.Services.AddHostedSingleton<IUserService, UserService>();
builder.Services.AddHostedSingleton<ISessionService, SessionService>(); builder.Services.AddHostedSingleton<ISessionService, SessionService>();
builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();

View File

@ -8,6 +8,31 @@ namespace OliverBooth.Services;
/// </summary> /// </summary>
public interface IUserService public interface IUserService
{ {
/// <summary>
/// Clears all expired tokens.
/// </summary>
void ClearExpiredTokens();
/// <summary>
/// Clears all tokens.
/// </summary>
void ClearTokens();
/// <summary>
/// Creates a temporary MFA token for the specified user.
/// </summary>
/// <param name="user">The user for whom to create the token.</param>
/// <returns>The newly-created token.</returns>
/// <exception cref="ArgumentNullException"><paramref name="user" /> is <see langword="null" />.</exception>
IMfaToken CreateMfaToken(IUser user);
/// <summary>
/// Deletes the specified token.
/// </summary>
/// <param name="token">The token to delete.</param>
/// <exception cref="ArgumentNullException"><paramref name="token" /> is <see langword="null" />.</exception>
void DeleteToken(string token);
/// <summary> /// <summary>
/// Attempts to find a user with the specified ID. /// Attempts to find a user with the specified ID.
/// </summary> /// </summary>
@ -33,5 +58,24 @@ public interface IUserService
/// <returns> /// <returns>
/// <see langword="true" /> if the login credentials are valid; otherwise, <see langword="false" />. /// <see langword="true" /> if the login credentials are valid; otherwise, <see langword="false" />.
/// </returns> /// </returns>
public bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user); bool VerifyLogin(string email, string password, [NotNullWhen(true)] out IUser? user);
/// <summary>
/// Verifies the MFA request for the specified user.
/// </summary>
/// <param name="token">The MFA token.</param>
/// <param name="totp">The user-provided TOTP.</param>
/// <param name="user">
/// When this method returns, contains the user associated with the specified token, if the verification was
/// successful; otherwise, <see langword="null" />.
/// </param>
/// <returns>
/// An <see cref="MfaRequestResult" /> representing the result of the request.
/// </returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="token" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="totp" /> is <see langword="null" />.</para>
/// </exception>
MfaRequestResult VerifyMfaRequest(string token, string totp, out IUser? user);
} }

View File

@ -1,18 +1,23 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
using BC = BCrypt.Net.BCrypt; using BC = BCrypt.Net.BCrypt;
using Timer = System.Timers.Timer;
namespace OliverBooth.Services; namespace OliverBooth.Services;
/// <summary> /// <summary>
/// Represents an implementation of <see cref="IUserService" />. /// Represents an implementation of <see cref="IUserService" />.
/// </summary> /// </summary>
internal sealed class UserService : IUserService internal sealed class UserService : BackgroundService, IUserService
{ {
private static readonly RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create();
private readonly IDbContextFactory<WebContext> _dbContextFactory; private readonly IDbContextFactory<WebContext> _dbContextFactory;
private readonly ConcurrentDictionary<Guid, IUser> _userCache = new(); private readonly ConcurrentDictionary<Guid, IUser> _userCache = new();
private readonly ConcurrentDictionary<string, MfaToken> _tokenCache = new();
private readonly Timer _tokenClearTimer = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserService" /> class. /// Initializes a new instance of the <see cref="UserService" /> class.
@ -23,6 +28,90 @@ internal sealed class UserService : IUserService
public UserService(IDbContextFactory<WebContext> dbContextFactory) public UserService(IDbContextFactory<WebContext> dbContextFactory)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_tokenClearTimer.Interval = TimeSpan.FromMinutes(5).TotalMilliseconds;
_tokenClearTimer.Elapsed += (_, _) => ClearExpiredTokens();
}
/// <inheritdoc />
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 _);
}
}
/// <inheritdoc />
public void ClearTokens()
{
_tokenCache.Clear();
}
/// <inheritdoc />
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<byte>
// while still returning a string necessary for the IMfaToken model
static string CreateToken()
{
ReadOnlySpan<char> hexChars = "0123456789ABCDEF";
Span<char> chars = stackalloc char[128];
Span<byte> 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();
}
}
/// <inheritdoc />
public void DeleteToken(string token)
{
if (token is null)
{
throw new ArgumentNullException(nameof(token));
}
_tokenCache.TryRemove(token, out _);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -51,4 +140,54 @@ internal sealed class UserService : IUserService
user = context.Users.FirstOrDefault(u => u.EmailAddress == email); user = context.Users.FirstOrDefault(u => u.EmailAddress == email);
return user is not null && BC.Verify(password, ((User)user).Password); return user is not null && BC.Verify(password, ((User)user).Password);
} }
/// <inheritdoc />
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;
}
/// <inheritdoc />
public override Task StopAsync(CancellationToken cancellationToken)
{
_tokenClearTimer.Stop();
return base.StopAsync(cancellationToken);
}
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
ClearTokens();
_tokenClearTimer.Start();
return Task.CompletedTask;
}
} }