feat: implement MFA for admin login
This commit is contained in:
parent
d38167bb97
commit
faf3c4c3a8
@ -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);
|
||||||
|
37
OliverBooth/Data/Web/IMfaToken.cs
Normal file
37
OliverBooth/Data/Web/IMfaToken.cs
Normal 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; }
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
29
OliverBooth/Data/Web/MfaRequestResult.cs
Normal file
29
OliverBooth/Data/Web/MfaRequestResult.cs
Normal 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,
|
||||||
|
}
|
19
OliverBooth/Data/Web/MfaToken.cs
Normal file
19
OliverBooth/Data/Web/MfaToken.cs
Normal 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!;
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"/>
|
||||||
|
35
OliverBooth/Pages/Admin/MultiFactorStep.cshtml
Normal file
35
OliverBooth/Pages/Admin/MultiFactorStep.cshtml
Normal 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>
|
27
OliverBooth/Pages/Admin/MultiFactorStep.cshtml.cs
Normal file
27
OliverBooth/Pages/Admin/MultiFactorStep.cshtml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user