aboutsummaryrefslogtreecommitdiff
path: root/plugins/VNLib.Plugins.Essentials.Accounts/src
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-03-09 01:48:39 -0500
committerLibravatar vnugent <public@vaughnnugent.com>2023-03-09 01:48:39 -0500
commit03f3226ea055dca3565bb859437624ef04a236fd (patch)
treec3aae503ae9b459a6fcaf9a18891d11ee8e1d1d8 /plugins/VNLib.Plugins.Essentials.Accounts/src
parent0e78874a09767aa53122a7242a8da7021020c1a2 (diff)
Omega cache, session, and account provider complete overhaul
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs55
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs20
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs149
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs6
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs97
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs144
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs212
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs170
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs848
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs4
11 files changed, 1363 insertions, 350 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
index f4401a9..005953f 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -25,14 +25,17 @@
using System;
using System.Linq;
using System.Collections.Generic;
+using System.ComponentModel.Design;
using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
+using VNLib.Plugins.Attributes;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Accounts.Endpoints;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Extensions.Loading.Routing;
+using VNLib.Plugins.Essentials.Accounts.SecurityProvider;
namespace VNLib.Plugins.Essentials.Accounts
{
@@ -41,34 +44,44 @@ namespace VNLib.Plugins.Essentials.Accounts
public override string PluginName => "Essentials.Accounts";
- protected override void OnLoad()
+ private IAccountSecurityProvider? _securityProvider;
+
+ [ServiceConfigurator]
+ public void ConfigureServices(IServiceContainer services)
{
- try
+ //Export the build in security provider
+ if (_securityProvider != null)
{
- //Route endpoints
- this.Route<LoginEndpoint>();
+ services.AddService(typeof(IAccountSecurityProvider), _securityProvider);
+ }
+ }
- this.Route<LogoutEndpoint>();
+ protected override void OnLoad()
+ {
+ //Route endpoints
+ this.Route<LoginEndpoint>();
- this.Route<KeepAliveEndpoint>();
+ this.Route<LogoutEndpoint>();
- this.Route<ProfileEndpoint>();
+ this.Route<KeepAliveEndpoint>();
- this.Route<PasswordChangeEndpoint>();
+ this.Route<ProfileEndpoint>();
- this.Route<MFAEndpoint>();
+ this.Route<PasswordChangeEndpoint>();
- //Write loaded to log
- Log.Information("Plugin loaded");
- }
- catch (KeyNotFoundException knf)
- {
- Log.Error("Missing required account configuration variables {mess}", knf.Message);
- }
- catch (UriFormatException uri)
+ this.Route<MFAEndpoint>();
+
+ //Only export the account security service if the configuration element is defined
+ if (this.HasConfigForType<AccountSecProvider>())
{
- Log.Error("Invalid endpoint URI {message}", uri.Message);
+ //Inint the security provider
+ _securityProvider = this.GetOrCreateSingleton<AccountSecProvider>();
+
+ Log.Information("Configuring the account security provider service");
}
+
+ //Write loaded to log
+ Log.Information("Plugin loaded");
}
@@ -88,8 +101,8 @@ namespace VNLib.Plugins.Essentials.Accounts
}
try
{
- IUserManager Users = this.GetUserManager();
- PasswordHashing Passwords = this.GetPasswords();
+ IUserManager Users = this.GetOrCreateSingleton<UserManager>();
+ IPasswordHashingProvider Passwords = this.GetPasswords();
//get args as a list
List<string> args = cmd.Split(' ').ToList();
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs
index 0ff0869..e540405 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -24,8 +24,6 @@
using System;
using System.Net;
-using System.Text.Json;
-using System.Collections.Generic;
using VNLib.Utils.Extensions;
using VNLib.Plugins.Essentials.Endpoints;
@@ -44,7 +42,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
* Endpoint does not use a log, so IniPathAndLog is never called
* and path verification happens verbosly
*/
- public KeepAliveEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public KeepAliveEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
@@ -63,18 +61,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Allow post to update user's credentials
protected override VfReturnType Post(HttpEntity entity)
{
- //Get the last token update
- DateTimeOffset lastTokenUpdate = entity.Session.LastTokenUpgrade();
-
- //See if its expired
- if (lastTokenUpdate.Add(tokenRegenTime) < entity.RequestedTimeUtc)
+ //See if its time to regenreate the client's auth status
+ if (entity.Session.Created.Add(tokenRegenTime) < entity.RequestedTimeUtc)
{
- //if so updaet token
WebMessage webm = new()
{
- Token = entity.RegenerateClientToken(),
Success = true
};
+
+ //reauthorize the client
+ entity.ReAuthorizeClient(webm);
+
+ webm.Success = true;
//Send the update message to the client
entity.CloseResponse(webm);
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
index f973fe8..e78d2da 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -26,12 +26,9 @@ using System;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
-using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
-using VNLib.Hashing;
-using VNLib.Utils;
using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
@@ -44,7 +41,6 @@ using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
-using static VNLib.Plugins.Essentials.Accounts.AccountUtil;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
@@ -62,13 +58,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private static readonly LoginMessageValidation LmValidator = new();
- private readonly PasswordHashing Passwords;
+ private readonly IPasswordHashingProvider Passwords;
private readonly MFAConfig? MultiFactor;
private readonly IUserManager Users;
private readonly uint MaxFailedLogins;
private readonly TimeSpan FailedCountTimeout;
- public LoginEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public LoginEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
FailedCountTimeout = config["failed_count_timeout_sec"].GetTimeSpan(TimeParseType.Seconds);
@@ -77,8 +73,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
InitPathAndLog(path, pbase.Log);
Passwords = pbase.GetPasswords();
- Users = pbase.GetUserManager();
- MultiFactor = pbase.GetMfaConfig();
+ Users = pbase.GetOrCreateSingleton<UserManager>();
+ MultiFactor = pbase.GetConfigElement<MFAConfig>();
}
private class MfaUpgradeWebm : ValErrWebMessage
@@ -94,7 +90,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
protected async override ValueTask<VfReturnType> PostAsync(HttpEntity entity)
{
//Conflict if user is logged in
- if (entity.LoginCookieMatches() || entity.TokenMatches())
+ if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any))
{
entity.CloseResponse(HttpStatusCode.Conflict);
return VfReturnType.VirtualSkip;
@@ -137,6 +133,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Time to get the user
using IUser? user = await Users.GetUserAndPassFromEmailAsync(loginMessage.UserName);
+
//Make sure account exists
if (webm.Assert(user != null, INVALID_MESSAGE))
{
@@ -187,51 +184,55 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
user.FailedLoginCount(0);
try
{
- switch (user.Status)
+ if (user.Status == UserStatus.Active)
{
- case UserStatus.Active:
- {
- //Is the account restricted to a local network connection?
- if (user.LocalOnly && !entity.IsLocalConnection)
- {
- Log.Information("User {uid} attempted a login from a non-local network with the correct password. Access was denied", user.UserID);
- return false;
- }
- //Gen and store the pw secret
- byte[] pwSecret = entity.Session.GenPasswordChallenge(new(loginMessage.Password, false));
- //Encrypt and convert to base64
- string clientPwSecret = EncryptSecret(loginMessage.ClientPublicKey, pwSecret);
- //get the new upgrade jwt string
- Tuple<string,string>? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage, clientPwSecret);
- //if message is null, mfa was not enabled or could not be prepared
- if (message != null)
- {
- //Store the base64 signature
- entity.Session.MfaUpgradeSignature(message.Item2);
- //send challenge message to client
- webm.Result = message.Item1;
- webm.Success = true;
- webm.MultiFactorUpgrade = true;
- break;
- }
- //Set password token
- webm.PasswordToken = clientPwSecret;
- //Elevate the login status of the session to reflect the user's status
- webm.Token = entity.GenerateAuthorization(loginMessage, user);
- //Send the Username (since they already have it)
- webm.Result = new AccountData()
- {
- EmailAddress = user.EmailAddress,
- };
- webm.Success = true;
- //Write to log
- Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
- }
- break;
- default:
- //This is an unhandled case, and should never happen, but just incase write a warning to the log
- Log.Warn("Account {uid} has invalid status key and a login was attempted from {ip}", user.UserID, entity.TrustedRemoteIp);
+ //Is the account restricted to a local network connection?
+ if (user.LocalOnly && !entity.IsLocalConnection)
+ {
+ Log.Information("User {uid} attempted a login from a non-local network with the correct password. Access was denied", user.UserID);
return false;
+ }
+
+ //get the new upgrade jwt string
+ Tuple<string, string>? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage);
+
+ //if message is null, mfa was not enabled or could not be prepared
+ if (message != null)
+ {
+ //Store the base64 signature
+ entity.Session.MfaUpgradeSecret(message.Item2);
+
+ //send challenge message to client
+ webm.Result = message.Item1;
+ webm.Success = true;
+ webm.MultiFactorUpgrade = true;
+
+ return true;
+ }
+
+ //Set password token
+ webm.PasswordToken = null;
+
+ //Elevate the login status of the session to reflect the user's status
+ entity.GenerateAuthorization(loginMessage, user, webm);
+
+ //Send the Username (since they already have it)
+ webm.Result = new AccountData()
+ {
+ EmailAddress = user.EmailAddress,
+ };
+
+ webm.Success = true;
+ //Write to log
+ Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
+
+ return true;
+ }
+ else
+ {
+ //This is an unhandled case, and should never happen, but just incase write a warning to the log
+ Log.Warn("Account {uid} has invalid status key and a login was attempted from {ip}", user.UserID, entity.TrustedRemoteIp);
+ return false;
}
}
/*
@@ -248,22 +249,26 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
webm.Result = "Your browser sent malformatted security information";
Log.Debug(ce);
}
- return true;
+ return false;
}
private async ValueTask<VfReturnType> ProcessMfaAsync(HttpEntity entity)
{
MfaUpgradeWebm webm = new();
+
//Recover request message
using JsonDocument? request = await entity.GetJsonFromFileAsync();
+
if (webm.Assert(request != null, "Invalid request data"))
{
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
return VfReturnType.VirtualSkip;
}
+
//Recover upgrade jwt
string? upgradeJwt = request.RootElement.GetPropString("upgrade");
+
if (webm.Assert(upgradeJwt != null, "Missing required upgrade data"))
{
entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
@@ -271,17 +276,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Recover stored signature
- string? storedSig = entity.Session.MfaUpgradeSignature();
+ string? storedSig = entity.Session.MfaUpgradeSecret();
+
if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
-
+
//Recover upgrade data from upgrade message
- if (!MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig, out MFAUpgrade? upgrade))
+ MFAUpgrade? upgrade = MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig);
+
+ if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE))
{
- webm.Result = MFA_ERROR_MESSAGE;
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
@@ -306,11 +313,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
else
{
//Locked, so clear stored signature
- entity.Session.MfaUpgradeSignature(null);
+ entity.Session.MfaUpgradeSecret(null);
}
//Update user on clean process
await user.ReleaseAsync();
+
//Close rseponse
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
@@ -350,19 +358,21 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Wipe session signature
- entity.Session.MfaUpgradeSignature(null);
+ entity.Session.MfaUpgradeSecret(null);
//build login message from upgrade
LoginMessage loginMessage = new()
{
- ClientID = upgrade.ClientID,
+ ClientId = upgrade.ClientID,
ClientPublicKey = upgrade.Base64PubKey,
LocalLanguage = upgrade.ClientLocalLanguage,
LocalTime = localTime,
UserName = upgrade.UserName
};
+
//Elevate the login status of the session to reflect the user's status
- webm.Token = entity.GenerateAuthorization(loginMessage, user);
+ entity.GenerateAuthorization(loginMessage, user, webm);
+
//Set the password token as the password field of the login message
webm.PasswordToken = upgrade.PwClientData;
//Send the Username (since they already have it)
@@ -375,21 +385,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
}
- private static string EncryptSecret(string pubKey, byte[] secret)
- {
- //Alloc buffer for secret
- using IMemoryHandle<byte> buffer = MemoryUtil.SafeAlloc<byte>(4096);
-
- //Try to encrypt the data
- ERRNO count = TryEncryptClientData(pubKey, secret, buffer.Span);
-
- //Clear secret
- RandomHash.GetRandomBytes(secret);
-
- //Convert to base64 string
- return Convert.ToBase64String(buffer.Span[..(int)count]);
- }
-
public bool UserLoginLocked(IUser user)
{
//Recover last counter value
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs
index cc36609..9c304cd 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -24,8 +24,6 @@
using System;
using System.Net;
-using System.Text.Json;
-using System.Collections.Generic;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Essentials.Endpoints;
@@ -36,7 +34,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
internal class LogoutEndpoint : ProtectedWebEndpoint
{
- public LogoutEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public LogoutEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
InitPathAndLog(path, pbase.Log);
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
index df20084..0b015a4 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -29,7 +29,6 @@ using System.Threading.Tasks;
using System.Collections.Generic;
using System.Text.Json.Serialization;
-using VNLib.Hashing;
using VNLib.Utils;
using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
@@ -51,14 +50,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private readonly IUserManager Users;
private readonly MFAConfig? MultiFactor;
+ private readonly IPasswordHashingProvider Passwords;
- public MFAEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public MFAEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
InitPathAndLog(path, pbase.Log);
- Users = pbase.GetUserManager();
- MultiFactor = pbase.GetMfaConfig();
+ Users = pbase.GetOrCreateSingleton<UserManager>();
+ MultiFactor = pbase.GetConfigElement<MFAConfig>();
+ Passwords = pbase.GetPasswords();
}
private class TOTPUpdateMessage
@@ -78,18 +79,22 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity)
{
List<string> enabledModes = new(2);
+
//Load the MFA entry for the user
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
+
//Set the TOTP flag if set
if (!string.IsNullOrWhiteSpace(user?.MFAGetTOTPSecret()))
{
enabledModes.Add("totp");
}
+
//TODO Set fido flag if enabled
if (!string.IsNullOrWhiteSpace(""))
{
enabledModes.Add("fido");
}
+
//Return mfa modes as an array
entity.CloseResponseJson(HttpStatusCode.OK, enabledModes);
return VfReturnType.VirtualSkip;
@@ -101,6 +106,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Get the request message
using JsonDocument? mfaRequest = await entity.GetJsonFromFileAsync();
+
if (webm.Assert(mfaRequest != null, "Invalid request"))
{
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
@@ -130,8 +136,17 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VfReturnType.VirtualSkip;
}
+ //Get the user entry
+ using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
+
+ if (webm.Assert(user != null, "Please log-out and try again."))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
//get the user's password challenge
- using (PrivateString? password = (PrivateString?)mfaRequest.RootElement.GetPropString("challenge"))
+ using (PrivateString? password = (PrivateString?)mfaRequest.RootElement.GetPropString("password"))
{
if (PrivateString.IsNullOrEmpty(password))
{
@@ -139,26 +154,28 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
return VfReturnType.VirtualSkip;
}
- //Verify challenge
- if (!entity.Session.VerifyChallenge(password))
+
+ //Verify password against the user
+ if (!user.VerifyPassword(password, Passwords))
{
webm.Result = "Please check your password";
entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
return VfReturnType.VirtualSkip;
}
}
- //Get the user entry
- using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
- if (webm.Assert(user != null, "Please log-out and try again."))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
+
switch (mfaType.ToLower())
{
//Process a Time based one time password(TOTP) creation/regeneration
case "totp":
{
+ //Confirm totp is enabled
+ if (webm.Assert(MultiFactor.TOTPEnabled, "TOTP is not enabled on the current server"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
//generate a new secret (passing the buffer which will get copied to an array because the pw bytes can be modified during encryption)
byte[] secretBuffer = user.MFAGenreateTOTPSecret(MultiFactor);
//Alloc output buffer
@@ -167,7 +184,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
try
{
//Encrypt the secret for the client
- ERRNO count = entity.Session.TryEncryptClientData(secretBuffer, outputBuffer.Span);
+ ERRNO count = entity.TryEncryptClientData(secretBuffer, outputBuffer.Span);
if (!count)
{
@@ -179,10 +196,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
webm.Result = new TOTPUpdateMessage()
{
- Issuer = MultiFactor.IssuerName,
- Digits = MultiFactor.TOTPDigits,
- Period = (int)MultiFactor.TOTPPeriod.TotalSeconds,
- Algorithm = MultiFactor.TOTPAlg.ToString(),
+ Issuer = MultiFactor.TOTPConfig.IssuerName,
+ Digits = MultiFactor.TOTPConfig.TOTPDigits,
+ Period = (int)MultiFactor.TOTPConfig.TOTPPeriod.TotalSeconds,
+ Algorithm = MultiFactor.TOTPConfig.TOTPAlg.ToString(),
//Convert the secret to base64 string to send to client
Base64EncSecret = Convert.ToBase64String(outputBuffer.Span[..(int)count])
};
@@ -194,7 +211,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
//dispose the output buffer
outputBuffer.Dispose();
- RandomHash.GetRandomBytes(secretBuffer);
+ MemoryUtil.InitializeBlock(secretBuffer.AsSpan());
}
//Only write changes to the db of operation was successful
await user.ReleaseAsync();
@@ -229,25 +246,38 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
return VfReturnType.VirtualSkip;
}
- /*
- * An MFA upgrade requires a challenge to be verified because
- * it can break the user's ability to access their account
- */
- string? challenge = request.RootElement.GetProperty("challenge").GetString();
+
string? mfaType = request.RootElement.GetProperty("type").GetString();
- if (!entity.Session.VerifyChallenge(challenge))
- {
- webm.Result = "Please check your password";
- //return unauthorized
- entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
- return VfReturnType.VirtualSkip;
- }
+
//get the user
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
if (user == null)
{
return VfReturnType.NotFound;
}
+
+ /*
+ * An MFA upgrade requires a challenge to be verified because
+ * it can break the user's ability to access their account
+ */
+ using (PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password"))
+ {
+ if (PrivateString.IsNullOrEmpty(password))
+ {
+ webm.Result = "Please check your password";
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Verify password against the user
+ if (!user.VerifyPassword(password, Passwords))
+ {
+ webm.Result = "Please check your password";
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+
//Check for totp disable
if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
{
@@ -271,6 +301,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
webm.Result = "Invalid MFA type";
}
+
//Must write response while password is in scope
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
index be109d1..c561b69 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -24,9 +24,8 @@
using System;
using System.Net;
-using System.Text.Json;
using System.Threading.Tasks;
-using System.Collections.Generic;
+using System.Text.Json.Serialization;
using FluentValidation;
@@ -38,10 +37,22 @@ using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Accounts.MFA;
+
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
+ /*
+ * SECURITY NOTES:
+ *
+ * If no MFA configuration is loaded for this plugin, users will
+ * be permitted to change passwords without thier 2nd factor.
+ *
+ * This decision was made to allow users with MFA enabled from a previous
+ * config to change their passwords rather than deny them the ability.
+ */
+
/// <summary>
/// Password reset for user's that are logged in and know
/// their passwords to reset their MFA methods
@@ -50,82 +61,114 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
internal sealed class PasswordChangeEndpoint : ProtectedWebEndpoint
{
private readonly IUserManager Users;
- private readonly PasswordHashing Passwords;
+ private readonly IPasswordHashingProvider Passwords;
+ private readonly MFAConfig? mFAConfig;
+ private readonly IValidator<PasswordResetMesage> ResetMessValidator;
- public PasswordChangeEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public PasswordChangeEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
InitPathAndLog(path, pbase.Log);
- Users = pbase.GetUserManager();
+ Users = pbase.GetOrCreateSingleton<UserManager>();
Passwords = pbase.GetPasswords();
+ ResetMessValidator = GetMessageValidator();
+ mFAConfig = pbase.GetConfigElement<MFAConfig>();
+ }
+
+ private static IValidator<PasswordResetMesage> GetMessageValidator()
+ {
+ InlineValidator<PasswordResetMesage> rules = new();
+
+ rules.RuleFor(static pw => pw.Current)
+ .NotEmpty()
+ .WithMessage("You must specify your current password")
+ .Length(8, 100);
+
+ //Use centralized password validator for new passwords
+ rules.RuleFor(static pw => pw.NewPassword)
+ .NotEmpty()
+ .NotEqual(static pm => pm.Current)
+ .WithMessage("Your new password may not equal your new current password")
+ .SetValidator(AccountValidations.PasswordValidator!);
+
+ return rules;
}
+ /*
+ * If mfa config
+ */
+
protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
//get the request body
- using JsonDocument? request = await entity.GetJsonFromFileAsync();
+ using PasswordResetMesage? pwReset = await entity.GetJsonFromFileAsync<PasswordResetMesage>();
- if (request == null)
+ if (webm.Assert(pwReset != null, "No request specified"))
{
- webm.Result = "No request specified";
entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
return VfReturnType.VirtualSkip;
}
- //get the user's old password
- using PrivateString? currentPass = (PrivateString?)request.RootElement.GetPropString("current");
- //Get password as a private string
- using PrivateString? newPass = (PrivateString?)request.RootElement.GetPropString("new_password");
-
- if (PrivateString.IsNullOrEmpty(currentPass))
- {
- webm.Result = "You must specifiy your current password.";
- entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
- return VfReturnType.VirtualSkip;
- }
- if (PrivateString.IsNullOrEmpty(newPass))
- {
- webm.Result = "You must specifiy a new password.";
- entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
- return VfReturnType.VirtualSkip;
- }
-
- //Test the password against minimum
- if (!AccountValidations.PasswordValidator.Validate((string)newPass, webm))
- {
- entity.CloseResponse(webm);
- return VfReturnType.VirtualSkip;
- }
- if (webm.Assert(!currentPass.Equals(newPass), "Passwords cannot be the same."))
+ //Validate
+ if(!ResetMessValidator.Validate(pwReset, webm))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
//get the user's entry in the table
- using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID);
+ using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID);
+
if(webm.Assert(user != null, "An error has occured, please log-out and try again"))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
+
//Make sure the account's origin is a local profile
if (webm.Assert(user.IsLocalAccount(), "External accounts cannot be modified"))
{
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
+
//Verify the user's old password
- if (!Passwords.Verify(user.PassHash, currentPass))
+ if (!Passwords.Verify(user.PassHash, pwReset.Current.AsSpan()))
{
webm.Result = "Please check your current password";
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
+
+ //Check if totp is enabled
+ if (user.MFATotpEnabled())
+ {
+ if(mFAConfig != null)
+ {
+ //TOTP code is required
+ if(webm.Assert(pwReset.TotpCode.HasValue, "TOTP is enabled on this user account, you must enter your TOTP code."))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Veriy totp code
+ bool verified = mFAConfig.VerifyTOTP(user, pwReset.TotpCode.Value);
+
+ if (webm.Assert(verified, "Please check your TOTP code and try again"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ //continue
+ }
+
//Hash the user's new password
- using PrivateString newPassHash = Passwords.Hash(newPass);
+ using PrivateString newPassHash = Passwords.Hash(pwReset.NewPassword.AsSpan());
+
//Update the user's password
if (!await Users.UpdatePassAsync(user, newPassHash))
{
@@ -134,12 +177,39 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
+
+ //Publish to user database
await user.ReleaseAsync();
+
//delete the user's MFA entry so they can re-enable it
webm.Result = "Your password has been updated";
webm.Success = true;
entity.CloseResponse(webm);
return VfReturnType.VirtualSkip;
}
+
+ private sealed class PasswordResetMesage : PrivateStringManager
+ {
+ public PasswordResetMesage() : base(2)
+ {
+ }
+
+ [JsonPropertyName("current")]
+ public string? Current
+ {
+ get => this[0];
+ set => this[0] = value;
+ }
+
+ [JsonPropertyName("new_password")]
+ public string? NewPassword
+ {
+ get => this[1];
+ set => this[1] = value;
+ }
+
+ [JsonPropertyName("totp_code")]
+ public uint? TotpCode { get; set; }
+ }
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs
index 45908e7..7dfb8a7 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -24,9 +24,7 @@
using System;
using System.Net;
-using System.Text.Json;
using System.Threading.Tasks;
-using System.Collections.Generic;
using VNLib.Utils.Logging;
using VNLib.Plugins.Essentials.Users;
@@ -48,13 +46,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
private readonly IUserManager Users;
- public ProfileEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ public ProfileEndpoint(PluginBase pbase, IConfigScope config)
{
string? path = config["path"].GetString();
InitPathAndLog(path, pbase.Log);
//Store user system
- Users = pbase.GetUserManager();
+ Users = pbase.GetOrCreateSingleton<UserManager>();
}
protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity)
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
index 03d5a20..bb86a3f 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -23,81 +23,171 @@
*/
using System;
-using System.Linq;
-using System.Text.Json;
-using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
using VNLib.Hashing;
-using VNLib.Utils.Extensions;
-using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Extensions.Loading;
namespace VNLib.Plugins.Essentials.Accounts.MFA
-{
- internal class MFAConfig
+{
+
+ [ConfigurationName("mfa")]
+ internal class MFAConfig : IOnConfigValidation
+ {
+ private static IValidator<MFAConfig> GetValidator()
+ {
+ InlineValidator<MFAConfig> val = new();
+
+ val.RuleFor(c => c.UpgradeExpSeconds)
+ .GreaterThan(1)
+ .WithMessage("You must configure a non-zero upgrade expiration timeout");
+
+ val.RuleFor(c => c.NonceLenBytes)
+ .GreaterThanOrEqualTo(8)
+ .WithMessage("You must configure a nonce size of 8 bytes or larger");
+
+ val.RuleFor(c => c.UpgradeKeyBytes)
+ .GreaterThanOrEqualTo(8)
+ .WithMessage("You must configure a signing key size of 8 bytes or larger");
+
+ return val;
+ }
+
+ private static IValidator<MFAConfig> _validator { get; } = GetValidator();
+
+ [JsonPropertyName("totp")]
+ public TOTPConfig? TOTPConfig { get; set; }
+
+ [JsonIgnore]
+ public bool TOTPEnabled => TOTPConfig?.IssuerName != null;
+
+ [JsonPropertyName("fido")]
+ public FidoConfig? FIDOConfig { get; set; }
+
+ [JsonIgnore]
+ public bool FIDOEnabled => FIDOConfig?.FIDOSiteName != null;
+
+ [JsonIgnore]
+ public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120);
+
+ [JsonPropertyName("upgrade_expires_secs")]
+ public int UpgradeExpSeconds
+ {
+ get => (int)UpgradeValidFor.TotalSeconds;
+ set => UpgradeValidFor = TimeSpan.FromSeconds(value);
+ }
+
+ [JsonPropertyName("nonce_size")]
+ public int NonceLenBytes { get; set; } = 16;
+ [JsonPropertyName("upgrade_size")]
+ public int UpgradeKeyBytes { get; set; } = 32;
+
+
+ public void Validate()
+ {
+ //Validate the current confige before child configs
+ _validator.ValidateAndThrow(this);
+
+ TOTPConfig?.Validate();
+ FIDOConfig?.Validate();
+ }
+ }
+
+ internal class TOTPConfig : IOnConfigValidation
{
- public ReadOnlyJsonWebKey? MFASecret { get; set; }
+ private static IValidator<TOTPConfig> GetValidator()
+ {
+ InlineValidator<TOTPConfig> val = new();
+
+ val.RuleFor(c => c.IssuerName)
+ .NotEmpty();
+
+ val.RuleFor(c => c.PeriodSec)
+ .InclusiveBetween(1, 600);
- public bool TOTPEnabled { get; }
- public string? IssuerName { get; }
- public TimeSpan TOTPPeriod { get; }
- public HashAlg TOTPAlg { get; }
- public int TOTPDigits { get; }
- public int TOTPSecretBytes { get; }
- public int TOTPTimeWindowSteps { get; }
+ val.RuleFor(c => c.TOTPAlg)
+ .Must(a => a != HashAlg.None)
+ .WithMessage("TOTP Algorithim name must not be NONE");
+ val.RuleFor(c => c.TOTPDigits)
+ .GreaterThan(1)
+ .WithMessage("You should have more than 1 digit for a totp code");
- public bool FIDOEnabled { get; }
+ //We dont neet to check window steps, the user may want to configure 0 or more
+ val.RuleFor(c => c.TOTPTimeWindowSteps);
+
+ val.RuleFor(c => c.TOTPSecretBytes)
+ .GreaterThan(8)
+ .WithMessage("You should configure a larger TOTP secret size for better security");
+
+ return val;
+ }
+
+ [JsonIgnore]
+ private static IValidator<TOTPConfig> _validator { get; } = GetValidator();
+
+ [JsonPropertyName("issuer")]
+ public string? IssuerName { get; set; }
+
+ [JsonPropertyName("period_sec")]
+ public int PeriodSec
+ {
+ get => (int)TOTPPeriod.TotalSeconds;
+ set => TOTPPeriod = TimeSpan.FromSeconds(value);
+ }
+ [JsonIgnore]
+ public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30);
+
+
+ [JsonPropertyName("algorithm")]
+ public string AlgName
+ {
+ get => TOTPAlg.ToString();
+ set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null));
+ }
+ [JsonIgnore]
+ public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1;
+
+ [JsonPropertyName("digits")]
+ public int TOTPDigits { get; set; } = 6;
+
+ [JsonPropertyName("secret_size")]
+ public int TOTPSecretBytes { get; set; } = 32;
+
+ [JsonPropertyName("window_size")]
+ public int TOTPTimeWindowSteps { get; set; } = 1;
+
+ public void Validate()
+ {
+ //Validate the current instance on the
+ _validator.ValidateAndThrow(this);
+ }
+ }
+
+ internal class FidoConfig : IOnConfigValidation
+ {
+ private static IValidator<FidoConfig> GetValidator()
+ {
+ InlineValidator<FidoConfig> val = new();
+
+
+ return val;
+ }
+
+ private static IValidator<FidoConfig> _validator { get; } = GetValidator();
+
+
public int FIDOChallangeSize { get; }
public int FIDOTimeout { get; }
public string? FIDOSiteName { get; }
public string? FIDOAttestationType { get; }
public FidoAuthenticatorSelection? FIDOAuthSelection { get; }
- public TimeSpan UpgradeValidFor { get; }
- public int NonceLenBytes { get; }
-
- public MFAConfig(IReadOnlyDictionary<string, JsonElement> conf)
+ public void Validate()
{
- UpgradeValidFor = conf["upgrade_expires_secs"].GetTimeSpan(TimeParseType.Seconds);
- NonceLenBytes = conf["nonce_size"].GetInt32();
- string siteName = conf["site_name"].GetString() ?? throw new KeyNotFoundException("Missing required key 'site_name' in 'mfa' config");
-
- //Totp setup
- if (conf.TryGetValue("totp", out JsonElement totpEl))
- {
- IReadOnlyDictionary<string, JsonElement> totp = totpEl.EnumerateObject().ToDictionary(k => k.Name, k => k.Value);
-
- //Get totp config
- IssuerName = siteName;
- //Get alg name
- string TOTPAlgName = totp["algorithm"].GetString()?.ToUpper() ?? throw new KeyNotFoundException("Missing required key 'algorithm' in plugin 'mfa' config");
- //Parse from enum string
- TOTPAlg = Enum.Parse<HashAlg>(TOTPAlgName);
-
-
- TOTPDigits = totp["digits"].GetInt32();
- TOTPPeriod = TimeSpan.FromSeconds(totp["period_secs"].GetInt32());
- TOTPSecretBytes = totp["secret_size"].GetInt32();
- TOTPTimeWindowSteps = totp["window_size"].GetInt32();
- //Set enabled flag
- TOTPEnabled = true;
- }
- //Fido setup
- if(conf.TryGetValue("fido", out JsonElement fidoEl))
- {
- IReadOnlyDictionary<string, JsonElement> fido = fidoEl.EnumerateObject().ToDictionary(k => k.Name, k => k.Value);
- FIDOChallangeSize = fido["challenge_size"].GetInt32();
- FIDOAttestationType = fido["attestation"].GetString();
- FIDOTimeout = fido["timeout"].GetInt32();
- FIDOSiteName = siteName;
- //Deserailze a
- if(fido.TryGetValue("authenticatorSelection", out JsonElement authSel))
- {
- FIDOAuthSelection = authSel.Deserialize<FidoAuthenticatorSelection>();
- }
- //Set enabled flag
- FIDOEnabled = true;
- }
+ _validator.ValidateAndThrow(this);
}
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
index f8d322b..f683a89 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -37,7 +37,6 @@ using VNLib.Utils.Extensions;
using VNLib.Hashing.IdentityUtility;
using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Sessions;
-using VNLib.Plugins.Extensions.Loading;
namespace VNLib.Plugins.Essentials.Accounts.MFA
{
@@ -73,8 +72,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// </summary>
/// <param name="user"></param>
/// <param name="secret">The base32 encoded TOTP secret</param>
- public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
-
+ public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
+
+ /// <summary>
+ /// Determines if the user account has TOTP enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has totp enabled, false otherwise</returns>
+ public static bool MFATotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]);
/// <summary>
/// Generates/overwrites the current user's TOTP secret entry and returns a
@@ -85,8 +90,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// <exception cref="OutOfMemoryException"></exception>
public static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config)
{
+ _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP");
//Generate a random key
- byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPSecretBytes);
+ byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes);
//Store secret in user storage
user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false));
//return the raw secret bytes
@@ -107,7 +113,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
{
//Get the base32 TOTP secret for the user and make sure its actually set
string base32Secret = user.MFAGetTOTPSecret();
- if (string.IsNullOrWhiteSpace(base32Secret))
+ if (!config.TOTPEnabled || string.IsNullOrWhiteSpace(base32Secret))
{
return false;
}
@@ -115,10 +121,10 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(base32Secret.Length, true);
ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer);
//Verify the TOTP using the decrypted secret
- return count && VerifyTOTP(code, buffer.AsSpan(0, count), config);
+ return count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig);
}
- private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, MFAConfig config)
+ private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, TOTPConfig config)
{
//A basic attempt at a constant time TOTP verification, run the calculation a fixed number of times, regardless of the resutls
bool codeMatches = false;
@@ -160,7 +166,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
return codeMatches;
}
- private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, MFAConfig config)
+ private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, TOTPConfig config)
{
//Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output
byte offset = (byte)(hash[^1] & 0x0Fu);
@@ -183,50 +189,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
#endregion
- #region loading
-
- const string MFA_CONFIG_KEY = "mfa";
-
- /// <summary>
- /// Gets the plugins ambient <see cref="PasswordHashing"/> if loaded, or loads it if required. This class will
- /// be unloaded when the plugin us unloaded.
- /// </summary>
- /// <param name="plugin"></param>
- /// <returns>The ambient <see cref="PasswordHashing"/></returns>
- /// <exception cref="OverflowException"></exception>
- /// <exception cref="KeyNotFoundException"></exception>
- /// <exception cref="ObjectDisposedException"></exception>
- public static MFAConfig? GetMfaConfig(this PluginBase plugin)
- {
- static MFAConfig? LoadMfaConfig(PluginBase pbase)
- {
- //Try to get the configuration object
- IReadOnlyDictionary<string, JsonElement>? conf = pbase.TryGetConfig(MFA_CONFIG_KEY);
-
- if (conf == null)
- {
- return null;
- }
- //Init mfa config
- MFAConfig mfa = new(conf);
-
- //Recover secret from config and dangerous 'lazy load'
- _ = pbase.ObserveTask(async () =>
- {
- mfa.MFASecret = await pbase.TryGetSecretAsync("mfa_secret").ToJsonWebKey();
-
- }, 50);
-
- return mfa;
- }
-
- plugin.ThrowIfUnloaded();
- //Get/load the passwords one time only
- return LoadingExtensions.GetOrCreateSingleton(plugin, LoadMfaConfig);
- }
-
- #endregion
-
#region pgp
private class PgpMfaCred
@@ -259,6 +221,20 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
#endregion
+ private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key);
+
+ private static ReadOnlyMemory<byte> UpgradeHeader { get; } = CompileJwtHeader();
+
+ private static byte[] CompileJwtHeader()
+ {
+ Dictionary<string, string> header = new()
+ {
+ { "alg","HS256" },
+ { "typ", "JWT" }
+ };
+ return JsonSerializer.SerializeToUtf8Bytes(header);
+ }
+
/// <summary>
/// Recovers a signed MFA upgrade JWT and verifies its authenticity, and confrims its not expired,
/// then recovers the upgrade mssage
@@ -266,39 +242,31 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// <param name="config"></param>
/// <param name="upgradeJwtString">The signed JWT upgrade message</param>
/// <param name="upgrade">The recovered upgrade</param>
- /// <param name="base64sessionSig">The stored base64 encoded signature from the session that requested an upgrade</param>
+ /// <param name="base32Secret">The stored base64 encoded signature from the session that requested an upgrade</param>
/// <returns>True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise</returns>
- public static bool RecoverUpgrade(this MFAConfig config, ReadOnlySpan<char> upgradeJwtString, ReadOnlySpan<char> base64sessionSig, [NotNullWhen(true)] out MFAUpgrade? upgrade)
+ public static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret)
{
- //Verifies a jwt stored signature against the actual signature
- static bool VerifyStoredSig(ReadOnlySpan<char> base64string, ReadOnlySpan<byte> signature)
- {
- using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(base64string.Length, true);
-
- //Recover base64
- ERRNO count = VnEncoding.TryFromBase64Chars(base64string, buffer.Span);
-
- //Compare
- return CryptographicOperations.FixedTimeEquals(signature, buffer.Span[..(int)count]);
- }
-
- //Verify config secret
- _ = config.MFASecret ?? throw new InvalidOperationException("MFA config is missing required upgrade signing key");
-
- upgrade = null;
-
//Parse jwt
using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString);
-
- if (!jwt.VerifyFromJwk(config.MFASecret))
+
+ //Recover the secret key
+ byte[] secret = VnEncoding.FromBase32String(base32Secret)!;
+ try
{
- return false;
- }
+ //Verify the
+ using HMAC hmac = GetSigningAlg(secret);
- if(!VerifyStoredSig(base64sessionSig, jwt.SignatureData))
+ if (!jwt.Verify(hmac))
+ {
+ return null;
+ }
+ }
+ finally
{
- return false;
+ //Erase secret
+ MemoryUtil.InitializeBlock(secret.AsSpan());
}
+ //Valid
//get request body
using JsonDocument doc = jwt.GetPayload();
@@ -310,12 +278,11 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
if (iat.Add(config.UpgradeValidFor) < DateTimeOffset.UtcNow)
{
//expired
- return false;
+ return null;
}
//Recover the upgrade message
- upgrade = doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>();
- return upgrade != null;
+ return doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>();
}
@@ -325,7 +292,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// <param name="login">The message from the user requesting the login</param>
/// <returns>A signed upgrade message the client will pass back to the server after the MFA verification</returns>
/// <exception cref="InvalidOperationException"></exception>
- public static Tuple<string, string>? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login, string pwClientData)
+ public static Tuple<string, string>? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login)
{
//Webauthn config
@@ -336,8 +303,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
//Check totp entry
if (!string.IsNullOrWhiteSpace(base32Secret))
{
- //Verify config secret
- _ = conf?.MFASecret ?? throw new InvalidOperationException("MFA config is missing required upgrade signing key");
//setup the upgrade
MFAUpgrade upgrade = new()
@@ -346,43 +311,50 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
Type = MFAType.TOTP,
//Store login message details
UserName = login.UserName,
- ClientID = login.ClientID,
+ ClientID = login.ClientId,
Base64PubKey = login.ClientPublicKey,
ClientLocalLanguage = login.LocalLanguage,
- PwClientData = pwClientData
};
//Init jwt for upgrade
- return GetUpgradeMessage(upgrade, conf.MFASecret, conf.UpgradeValidFor);
+ return GetUpgradeMessage(upgrade, conf);
}
return null;
}
- private static Tuple<string, string> GetUpgradeMessage(MFAUpgrade upgrade, ReadOnlyJsonWebKey secret, TimeSpan expires)
+ private static Tuple<string, string> GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config)
{
//Add some random entropy to the upgrade message, to help prevent forgery
- string entropy = RandomHash.GetRandomBase32(16);
+ string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes);
//Init jwt
using JsonWebToken upgradeJwt = new();
- upgradeJwt.WriteHeader(secret.JwtHeader);
+ //Add header
+ upgradeJwt.WriteHeader(UpgradeHeader.Span);
//Write claims
upgradeJwt.InitPayloadClaim()
.AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
.AddClaim("upgrade", upgrade)
- .AddClaim("type", upgrade.Type.ToString().ToLower())
- .AddClaim("expires", expires.TotalSeconds)
+ .AddClaim("type", upgrade.Type.ToString().ToLower(null))
+ .AddClaim("expires", config.UpgradeValidFor.TotalSeconds)
.AddClaim("a", entropy)
.CommitClaims();
-
- //Sign with jwk
- upgradeJwt.SignFromJwk(secret);
-
+
+ //Generate a new random secret
+ byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes);
+
+ //Init alg
+ using(HMAC alg = GetSigningAlg(secret))
+ {
+ //sign jwt
+ upgradeJwt.Sign(alg);
+ }
+
//compile and return jwt upgrade
- return new(upgradeJwt.Compile(), Convert.ToBase64String(upgradeJwt.SignatureData));
+ return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret));
}
- public static void MfaUpgradeSignature(this in SessionInfo session, string? base64Signature) => session[SESSION_SIG_KEY] = base64Signature!;
+ public static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!;
- public static string? MfaUpgradeSignature(this in SessionInfo session) => session[SESSION_SIG_KEY];
+ public static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY];
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
new file mode 100644
index 0000000..ded7709
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
@@ -0,0 +1,848 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: AccountSecProvider.cs
+*
+* AccountSecProvider.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+/*
+ * Implements the IAccountSecurityProvider interface to provide the shared
+ * service to the host application for securing user/account based connections
+ * via authorization.
+ *
+ * This system is technically configurable and optionally loadable
+ */
+
+using System;
+using System.Text;
+using System.Text.Json;
+using System.Security.Cryptography;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Utils;
+using VNLib.Net.Http;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Sessions;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Validation;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
+{
+ [ConfigurationName("account_security", Required = false)]
+ internal class AccountSecProvider : IAccountSecurityProvider
+ {
+ private const int PUB_KEY_JWT_NONCE_SIZE = 16;
+ private const int PUB_KEY_ENCODE_BUFFER_SIZE = 128;
+
+ //Session entry keys
+ private const string CLIENT_PUB_KEY_ENTRY = "acnt.pbk";
+ private const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk";
+
+
+ /// <summary>
+ /// The client data encryption padding.
+ /// </summary>
+ public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256;
+
+ /*
+ * Using the P-256 curve for message signing
+ */
+ private static readonly ECCurve DefaultCurv = ECCurve.NamedCurves.nistP256;
+ private static readonly HashAlgorithmName DefaultHashAlg = HashAlgorithmName.SHA256;
+
+ private static HMAC GetPubKeySigningAlg(byte[] key) => new HMACSHA256(key);
+
+ private readonly AccountSecConfig _config;
+
+ public AccountSecProvider(PluginBase plugin)
+ {
+ //Setup default config
+ _config = new();
+ }
+
+ public AccountSecProvider(PluginBase pbase, IConfigScope config)
+ {
+ //Parse config if defined
+ _config = config.DeserialzeAndValidate<AccountSecConfig>();
+ }
+
+ #region Interface Impl
+
+ IClientAuthorization IAccountSecurityProvider.AuthorizeClient(HttpEntity entity, IClientSecInfo clientInfo, IUser user)
+ {
+ //Validate client info
+ _ = clientInfo ?? throw new ArgumentNullException(nameof(clientInfo));
+ _ = clientInfo.PublicKey ?? throw new ArgumentException(nameof(clientInfo.PublicKey));
+ _ = clientInfo.ClientId ?? throw new ArgumentException(nameof(clientInfo.ClientId));
+
+ //Validate user
+ _ = user ?? throw new ArgumentNullException(nameof(user));
+
+ if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
+ {
+ throw new ArgumentException("The session is no configured for authorization");
+ }
+
+ //Generate the new client token for the client's public key
+ ClientSecurityToken authTokens = GenerateToken(clientInfo.PublicKey);
+
+ /*
+ * Create thet login cookie value, we need to pass the initial user account
+ * status for the user cookie. This is not required if the user is already
+ * logged in
+ */
+ string loginCookie = SetLoginCookie(entity, user.IsLocalAccount());
+
+ //Store the login hash in the user's session
+ entity.Session.LoginHash = loginCookie;
+ //Store the server token in the session
+ entity.Session.Token = authTokens.ServerToken;
+
+ /*
+ * The user's public key will be stored via a jwt cookie
+ * signed by this specific signing key, we will save the signing key
+ * in the session
+ */
+ string base32Key = SetPublicKeyCookie(entity, clientInfo.PublicKey);
+ entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = base32Key;
+
+ //Return the new authorzation
+ return new Authorization()
+ {
+ LoginSecurityString = loginCookie,
+ SecurityToken = authTokens,
+ };
+ }
+
+ void IAccountSecurityProvider.InvalidateLogin(HttpEntity entity)
+ {
+ //Client should also destroy the session
+ ExpireCookies(entity);
+
+ //Clear known security keys
+ entity.Session.Token = null!;
+ entity.Session.LoginHash = null!;
+ entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!;
+ }
+
+ bool IAccountSecurityProvider.IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level)
+ {
+ //Session must be loaded and not-new for an authorization to exist
+ if(!entity.Session.IsSet || entity.Session.IsNew)
+ {
+ return false;
+ }
+
+ switch (level)
+ {
+ //Accept the client token or the cookie as any/medium
+ case AuthorzationCheckLevel.Any:
+ case AuthorzationCheckLevel.Medium:
+ return VerifyLoginCookie(entity) || VerifyClientToken(entity);
+
+ //Critical requires that the client cookie is set and the token is set
+ case AuthorzationCheckLevel.Critical:
+ return VerifyLoginCookie(entity) && VerifyClientToken(entity);
+
+ //Default to false condition
+ default:
+ return false;
+ }
+ }
+
+ IClientAuthorization IAccountSecurityProvider.ReAuthorizeClient(HttpEntity entity)
+ {
+ //Confirm session is configured
+ if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
+ {
+ throw new InvalidOperationException ("The session is not configured for authorization");
+ }
+
+ //recover the client's public key
+ if(!TryGetPublicKey(entity, out string? pubKey))
+ {
+ throw new InvalidOperationException("The user does not have the required public key token stored");
+ }
+
+ //Try to generate a new authorization
+ ClientSecurityToken authTokens = GenerateToken(pubKey);
+
+ //Set login cookies with stored session data
+ string loginCookie = SetLoginCookie(entity);
+
+ //Update the public key cookie
+ string signingKey = SetPublicKeyCookie(entity, pubKey);
+ //Store signing key
+ entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = signingKey;
+
+ //Update token/login
+ entity.Session.LoginHash = loginCookie;
+ entity.Session.Token = authTokens.ServerToken;
+
+ //Return the new authorzation
+ return new Authorization()
+ {
+ LoginSecurityString = loginCookie,
+ SecurityToken = authTokens,
+ };
+ }
+
+ ERRNO IAccountSecurityProvider.TryEncryptClientData(HttpEntity entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer)
+ {
+ //Session must be enabled and not new
+ if (!entity.Session.IsSet || entity.Session.IsNew)
+ {
+ return false;
+ }
+
+ //try to get the public key from the client
+ string base64PubKey = entity.Session[CLIENT_PUB_KEY_ENTRY];
+
+ return TryEncryptClientData(base64PubKey, data, outputBuffer);
+ }
+
+ ERRNO IAccountSecurityProvider.TryEncryptClientData(IClientSecInfo entity, ReadOnlySpan<byte> data, Span<byte> outputBuffer)
+ {
+ //Use the public key supplied by the csecinfo
+ return TryEncryptClientData(entity.PublicKey, data, outputBuffer);
+ }
+
+ #endregion
+
+ #region Security Tokens
+
+ /*
+ * A client token was an older term used for a single random token generated
+ * by the server and sent by the client.
+ *
+ * The latest revision generates a keypair on authorization, the public key
+ * is stored id the client's session, and the private key gets encrypted
+ * and sent to the client. The client uses this ECDSA key to sign one time use
+ * JWT tokens
+ *
+ */
+
+ private ClientSecurityToken GenerateToken(ReadOnlySpan<char> publicKey)
+ {
+ static ReadOnlySpan<byte> PublicKey(ReadOnlySpan<char> publicKey, Span<byte> buffer)
+ {
+ ERRNO result = VnEncoding.TryFromBase64Chars(publicKey, buffer);
+ return buffer.Slice(0, result);
+ }
+
+ static string EpxortPubKey(ECDsa alg)
+ {
+ //Stack buffer
+ Span<byte> buffer = stackalloc byte[PUB_KEY_ENCODE_BUFFER_SIZE];
+
+ if(!alg.TryExportSubjectPublicKeyInfo(buffer, out int written))
+ {
+ throw new InternalBufferTooSmallException("Failed to export the public key because the internal buffer is too small");
+ }
+
+ //Convert to base64
+ string base64 = Convert.ToBase64String(buffer[..written]);
+ MemoryUtil.InitializeBlock(buffer);
+ return base64;
+ }
+
+ //Alloc buffer for encode/decode
+ using IMemoryHandle<byte> buffer = MemoryUtil.SafeAllocNearestPage<byte>(8000, true);
+ try
+ {
+ using RSA rsa = RSA.Create();
+
+ //Import the client's public key
+ rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _);
+
+ string pubKey;
+
+ Span<byte> privKeyBuffer = buffer.Span[..512];
+ Span<byte> outputBuffer = buffer.Span[512..];
+
+ //Init the ecdsa keypair for message signing
+ using (ECDsa keypair = ECDsa.Create(DefaultCurv))
+ {
+ //Export private key
+ pubKey = EpxortPubKey(keypair);
+ //Export private key to buffer
+ if(!keypair.TryExportPkcs8PrivateKey(privKeyBuffer, out int written))
+ {
+ throw new InternalBufferTooSmallException("Failed to export the client's new private key");
+ }
+ //resize the buffe
+ privKeyBuffer = privKeyBuffer[0..written];
+ }
+
+ //Encyrpt the private key to send to client
+ if (!rsa.TryEncrypt(privKeyBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted))
+ {
+ throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small");
+ }
+
+ //Convert the tokens to base64 encoding and return the new cst
+ return new()
+ {
+ //Client token is the encrypted private key
+ ClientToken = Convert.ToBase64String(outputBuffer[..bytesEncrypted]),
+ //Store public key as the server token
+ ServerToken = pubKey
+ };
+ }
+ finally
+ {
+ //Zero buffer when complete
+ MemoryUtil.InitializeBlock(buffer.Span);
+ }
+ }
+
+ private bool VerifyClientToken(HttpEntity entity)
+ {
+ static void InitPubKey(string privKey, ECDsa alg)
+ {
+ Span<byte> buffer = stackalloc byte[PUB_KEY_ENCODE_BUFFER_SIZE];
+ if(!Convert.TryFromBase64Chars(privKey, buffer, out int bytes))
+ {
+ throw new InternalBufferTooSmallException("The decoding buffer is too small to store the public key");
+ }
+
+ //Import private key
+ alg.ImportSubjectPublicKeyInfo(buffer[..bytes], out _);
+ }
+
+ //Get the token from the client header, the client should always sent this
+ string? signedMessage = entity.Server.Headers[_config.TokenHeaderName];
+
+ //Make sure a session is loaded
+ if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage))
+ {
+ return false;
+ }
+
+ //Get the stored public key
+ string publicKey = entity.Session.Token;
+ if (string.IsNullOrWhiteSpace(publicKey))
+ {
+ return false;
+ }
+
+ /*
+ * The clients signed message is a json web token that includes basic information
+ * Clients may send bad data, so we should swallow exceptions and return false
+ */
+
+ bool isValid = true;
+
+ try
+ {
+ //Parse the client jwt signed message
+ using JsonWebToken jwt = JsonWebToken.Parse(signedMessage);
+
+ //It should be verifiable from the stored public key
+ using(ECDsa alg = ECDsa.Create(DefaultCurv))
+ {
+ //Import public key
+ InitPubKey(publicKey, alg);
+
+ //Verify jwt
+ isValid &= jwt.Verify(alg, in DefaultHashAlg);
+ }
+
+ //Get the message payload
+ using JsonDocument data = jwt.GetPayload();
+
+ //Get iat time
+ if (data.RootElement.TryGetProperty("iat", out JsonElement iatEl))
+ {
+ //Try to get iat in uning seconds
+ isValid &= iatEl.TryGetInt64(out long iatSec);
+
+ //Recover dto from seconds
+ DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(iatSec);
+
+ //Verify iat against current time with allowed disparity
+ isValid &= iat.Add(_config.SignedTokenTimeDiff) > entity.RequestedTimeUtc;
+
+ //Message is too far into the future!
+ isValid &= iat.Subtract(_config.SignedTokenTimeDiff) < entity.RequestedTimeUtc;
+ }
+ else
+ {
+ //No time element provided
+ isValid = false;
+ }
+ }
+ catch (FormatException)
+ {
+ //we may catch the format exception for a malformatted jwt
+ isValid = false;
+ }
+
+ return isValid;
+ }
+ #endregion
+
+ #region Cookies
+
+ private bool VerifyLoginCookie(HttpEntity entity)
+ {
+ //Sessions must be loaded
+ if (!entity.Session.IsSet || entity.Session.IsNew)
+ {
+ return false;
+ }
+
+ //Try to get the login string from the request cookies
+ if (!entity.Server.RequestCookies.TryGetNonEmptyValue(_config.LoginCookieName, out string? cookie))
+ {
+ return false;
+ }
+
+ //Make sure a login hash is stored
+ if (string.IsNullOrWhiteSpace(entity.Session.LoginHash))
+ {
+ return false;
+ }
+
+
+ //Alloc buffer for decoding the base64 signatures
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage<byte>(2 * entity.Session.LoginHash.Length, true);
+
+ //Slice up buffers
+ Span<byte> cookieBuffer = buffer.Span[.._config.LoginCookieSize];
+ Span<byte> sessionBuffer = buffer.Span.Slice(_config.LoginCookieSize, _config.LoginCookieSize);
+
+ //Convert cookie and session hash value
+ if (Convert.TryFromBase64Chars(cookie, cookieBuffer, out int cookieBytesWriten)
+ && Convert.TryFromBase64Chars(entity.Session.LoginHash, sessionBuffer, out int hashBytesWritten))
+ {
+ //Do a fixed time equal (probably overkill, but should not matter too much)
+ if (CryptographicOperations.FixedTimeEquals(cookieBuffer[..cookieBytesWriten], sessionBuffer[..hashBytesWritten]))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void ExpireCookies(HttpEntity entity)
+ {
+ //Expire login cookie if set
+ if (entity.Server.RequestCookies.ContainsKey(_config.LoginCookieName))
+ {
+ entity.Server.ExpireCookie(_config.LoginCookieName, sameSite: CookieSameSite.SameSite);
+ }
+ //Expire the LI cookie if set
+ if (entity.Server.RequestCookies.ContainsKey(_config.ClientStatusCookieName))
+ {
+ entity.Server.ExpireCookie(_config.ClientStatusCookieName, sameSite: CookieSameSite.SameSite);
+ }
+ //Expire pupkey cookie
+ if (entity.Server.RequestCookies.ContainsKey(_config.PubKeyCookieName))
+ {
+ entity.Server.ExpireCookie(_config.PubKeyCookieName, sameSite: CookieSameSite.SameSite);
+ }
+ }
+
+ #endregion
+
+ #region Data Encryption
+
+ /// <summary>
+ /// Tries to encrypt the specified data using the specified public key
+ /// </summary>
+ /// <param name="base64PubKey">A base64 encoded public key used to encrypt client data</param>
+ /// <param name="data">Data to encrypt</param>
+ /// <param name="outputBuffer">The buffer to store encrypted data in</param>
+ /// <returns>
+ /// The number of encrypted bytes written to the output buffer,
+ /// or false (0) if the operation failed, or if no credential is
+ /// specified.
+ /// </returns>
+ /// <exception cref="CryptographicException"></exception>
+ private static ERRNO TryEncryptClientData(ReadOnlySpan<char> base64PubKey, ReadOnlySpan<byte> data, Span<byte> outputBuffer)
+ {
+ if (base64PubKey.IsEmpty)
+ {
+ return false;
+ }
+
+ //Alloc a buffer for decoding the public key
+ using UnsafeMemoryHandle<byte> pubKeyBuffer = MemoryUtil.UnsafeAllocNearestPage<byte>(base64PubKey.Length, true);
+
+ //Decode the public key
+ ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer.Span);
+
+ //Try to encrypt the data
+ return pbkBytesWritten ? TryEncryptClientData(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, outputBuffer) : ERRNO.E_FAIL;
+ }
+
+ /// <summary>
+ /// Tries to encrypt the specified data using the specified public key
+ /// </summary>
+ /// <param name="rawPubKey">The raw SKI public key</param>
+ /// <param name="data">Data to encrypt</param>
+ /// <param name="outputBuffer">The buffer to store encrypted data in</param>
+ /// <returns>
+ /// The number of encrypted bytes written to the output buffer,
+ /// or false (0) if the operation failed, or if no credential is
+ /// specified.
+ /// </returns>
+ /// <exception cref="CryptographicException"></exception>
+ private static ERRNO TryEncryptClientData(ReadOnlySpan<byte> rawPubKey, ReadOnlySpan<byte> data, Span<byte> outputBuffer)
+ {
+ if (rawPubKey.IsEmpty)
+ {
+ return false;
+ }
+
+ //Setup new empty rsa
+ using RSA rsa = RSA.Create();
+
+ //Import the public key
+ rsa.ImportSubjectPublicKeyInfo(rawPubKey, out _);
+
+ //Encrypt data with OaepSha256 as configured in the browser
+ return rsa.TryEncrypt(data, outputBuffer, ClientEncryptonPadding, out int bytesWritten) ? bytesWritten : ERRNO.E_FAIL;
+ }
+
+ #endregion
+
+ /// <summary>
+ /// Stores the login key as a cookie in the current session as long as the session exists
+ /// </summary>/
+ /// <param name="ev">The event to log-in</param>
+ /// <param name="localAccount">Does the session belong to a local user account</param>
+ private string SetLoginCookie(HttpEntity ev, bool? localAccount = null)
+ {
+ //Get the new random cookie value
+ string loginString = RandomHash.GetRandomBase64(_config.LoginCookieSize);
+
+ //Configure the login cookie
+ HttpCookie loginCookie = new(_config.LoginCookieName, loginString)
+ {
+ Domain = _config.CookieDomain,
+ Path = _config.CookiePath,
+ ValidFor = _config.AuthorizationValidFor,
+ SameSite = CookieSameSite.SameSite,
+ HttpOnly = true,
+ Secure = true
+ };
+
+ //Set login cookie and session login hash
+ ev.Server.SetCookie(in loginCookie);
+
+ //If not set get from session storage
+ localAccount ??= ev.Session.HasLocalAccount();
+
+ //setup status cookie
+ HttpCookie statusCookie = new(_config.ClientStatusCookieName, localAccount.Value ? "1" : "2")
+ {
+ Domain = _config.CookieDomain,
+ Path = _config.CookiePath,
+ ValidFor = _config.AuthorizationValidFor,
+ SameSite = CookieSameSite.SameSite,
+ Secure = true,
+
+ //Allowed to be http
+ HttpOnly = false
+ };
+
+ //Set the client identifier cookie to a value indicating a local account
+ ev.Server.SetCookie(in statusCookie);
+
+ return loginString;
+ }
+
+ #region Client Encryption Key
+
+ /*
+ * Stores the public key the client provided as a signed JWT a and sets
+ * it as a cookie in the user's browser.
+ *
+ * The signing key is randomly generated and stored in the client's session
+ * so it cannot "stolen"
+ *
+ * This was done mostly to save session storage space
+ */
+
+ private string SetPublicKeyCookie(HttpEntity entity, string pubKey)
+ {
+ //generate a random nonce
+ string nonce = RandomHash.GetRandomHex(PUB_KEY_JWT_NONCE_SIZE);
+
+ //Generate signing key
+ using JsonWebToken jwt = new();
+ //No header to write, we know the format
+
+ //add the clients public key and set iat/exp
+ jwt.InitPayloadClaim()
+ .AddClaim("sub", pubKey)
+ .AddClaim("iat", entity.RequestedTimeUtc.ToUnixTimeSeconds())
+ .AddClaim("exp", entity.RequestedTimeUtc.Add(_config.AuthorizationValidFor).ToUnixTimeSeconds())
+ .AddClaim("nonce", nonce)
+ .CommitClaims();
+
+ //genreate random signing key to store in the user's session
+ byte[] signingKey = RandomHash.GetRandomBytes(_config.PubKeySigningKeySize);
+
+ //Sign using hmac 256
+ using (HMAC hmac = GetPubKeySigningAlg(signingKey))
+ {
+ //Sign jwt
+ jwt.Sign(hmac);
+ }
+
+ //base32 encode the signing key
+ string base32SigningKey = VnEncoding.ToBase32String(signingKey, false);
+
+ //Zero signing key now were done using it
+ MemoryUtil.InitializeBlock(signingKey.AsSpan());
+
+ //Compile the jwt for the cookie value
+ string jwtValue = jwt.Compile();
+
+ //Setup cookie the same as login cookies
+ HttpCookie cookie = new(_config.PubKeyCookieName, jwtValue)
+ {
+ Domain = _config.CookieDomain,
+ Path = _config.CookiePath,
+ SameSite = CookieSameSite.SameSite,
+ ValidFor = _config.AuthorizationValidFor,
+
+ HttpOnly = true,
+ Secure = true,
+ };
+
+ //set the cookie
+ entity.Server.SetCookie(in cookie);
+
+ //Return the signing key
+ return base32SigningKey;
+ }
+
+ private bool TryGetPublicKey(HttpEntity entity, [NotNullWhen(true)] out string? pubKey)
+ {
+ pubKey = null;
+
+ if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
+ {
+ return false;
+ }
+
+ //Get the jwt cookie
+ if (!entity.Server.GetCookie(_config.PubKeyCookieName, out string? pubKeyJwt))
+ {
+ return false;
+ }
+
+ //Get the client signature
+ string? base32Sig = entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY];
+
+ if (string.IsNullOrWhiteSpace(base32Sig))
+ {
+ return false;
+ }
+
+ //Parse the jwt
+ using JsonWebToken jwt = JsonWebToken.Parse(pubKeyJwt);
+
+ //Recover the signing key bytes
+ byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!;
+
+ //verify the client signature
+ using (HMAC hmac = GetPubKeySigningAlg(signingKey))
+ {
+ if (!jwt.Verify(hmac))
+ {
+ return false;
+ }
+ }
+
+ //Verify expiration
+ using JsonDocument payload = jwt.GetPayload();
+
+ //Get the expiration time from the jwt
+ long expTimeSec = payload.RootElement.GetProperty("exp").GetInt64();
+ DateTimeOffset expired = DateTimeOffset.FromUnixTimeSeconds(expTimeSec);
+
+ //Check if expired
+ if (expired.Ticks < entity.RequestedTimeUtc.Ticks)
+ {
+ return false;
+ }
+
+ //Store the public key
+ pubKey = payload.RootElement.GetProperty("sub").GetString()!;
+
+ return true;
+ }
+
+ #endregion
+
+
+ private sealed class AccountSecConfig : IOnConfigValidation
+ {
+ private static IValidator<AccountSecConfig> _validator { get; } = GetValidator();
+
+ private static IValidator<AccountSecConfig> GetValidator()
+ {
+ InlineValidator<AccountSecConfig> val = new();
+
+ val.RuleFor(c => c.LoginCookieName)
+ .Length(1, 50)
+ .IllegalCharacters();
+
+ val.RuleFor(c => c.LoginCookieSize)
+ .InclusiveBetween(8, 4096)
+ .WithMessage("The login cookie size must be a sensable value between 8 bytes and 4096 bytes long");
+
+ //Cookie domain may be null/emmpty
+ val.RuleFor(c => c.CookieDomain);
+
+ //Cookie path may be empty or null
+ val.RuleFor(c => c.CookiePath);
+
+ val.RuleFor(c => c.AuthorizationValidFor)
+ .GreaterThan(TimeSpan.FromMinutes(1))
+ .WithMessage("The authorization should be valid for at-least 1 minute");
+
+ val.RuleFor(C => C.ClientStatusCookieName)
+ .Length(1, 50)
+ .AlphaNumericOnly();
+
+ //header name is required, but not allowed to contain "illegal" chars
+ val.RuleFor(c => c.TokenHeaderName)
+ .NotEmpty()
+ .IllegalCharacters();
+
+
+ val.RuleFor(c => c.PubKeyCookieName)
+ .Length(1, 50)
+ .IllegalCharacters();
+
+ //Signing keys are base32 encoded and stored in the session, we dont want to take up too much space
+ val.RuleFor(c => c.PubKeySigningKeySize)
+ .InclusiveBetween(8, 512)
+ .WithMessage("Your public key signing key should be between 8 and 512 bytes");
+
+ //Time difference doesnt need to be validated, it may be 0 to effectively disable it
+ val.RuleFor(c => c.SignedTokenTimeDiff);
+
+ return val;
+ }
+
+ /// <summary>
+ /// The name of the random security cookie
+ /// </summary>
+ [JsonPropertyName("login_cookie_name")]
+ public string LoginCookieName { get; set; } = "VNLogin";
+
+ /// <summary>
+ /// The size (in bytes) of the randomly generated security cookie
+ /// </summary>
+ [JsonPropertyName("login_cookie_size")]
+ public int LoginCookieSize { get; set; } = 64;
+
+ /// <summary>
+ /// The domain all authoization cookies will be set for
+ /// </summary>
+ [JsonPropertyName("cookie_domain")]
+ public string CookieDomain { get; set; } = "";
+
+ /// <summary>
+ /// The path all authorization cookies will be set for
+ /// </summary>
+ [JsonPropertyName("cookie_path")]
+ public string? CookiePath { get; set; } = "/";
+
+ /// <summary>
+ /// The amount if time new authorizations are valid for. This also
+ /// sets the duration of client cookies.
+ /// </summary>
+ [JsonIgnore]
+ internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60);
+
+ /// <summary>
+ /// The name of the cookie used to set the client's login status message
+ /// </summary>
+ [JsonPropertyName("status_cookie_name")]
+ public string ClientStatusCookieName { get; set; } = "li";
+
+ /// <summary>
+ /// The name of the header used by the client to send the one-time use
+ /// authorization token
+ /// </summary>
+ [JsonPropertyName("otp_header_name")]
+ public string TokenHeaderName { get; set; } = "X-Web-Token";
+
+ public int PasswordChallengeKeySize { get; set; } = 128;
+
+ /// <summary>
+ /// The name of the cookie that stores the user's signed public encryption key
+ /// </summary>
+ [JsonPropertyName("pubkey_cookie_name")]
+ public string PubKeyCookieName { get; set; } = "client_id";
+
+ /// <summary>
+ /// The size (in bytes) of the randomly generated key
+ /// used to sign the user's public key
+ /// </summary>
+ [JsonPropertyName("pubkey_signing_key_size")]
+ public int PubKeySigningKeySize { get; set; } = 32;
+
+ /// <summary>
+ /// The allowed time difference in the issuance time of the client's signed
+ /// one time use tokens
+ /// </summary>
+ [JsonIgnore]
+ internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30);
+
+ [JsonPropertyName("otp_time_diff_sec")]
+ public uint SigTokenTimeDifSeconds
+ {
+ get => (uint)SignedTokenTimeDiff.TotalSeconds;
+ set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value);
+ }
+
+ void IOnConfigValidation.Validate()
+ {
+ //Validate the current instance
+ _validator.ValidateAndThrow(this);
+ }
+ }
+
+ private sealed class Authorization : IClientAuthorization
+ {
+ public string? LoginSecurityString { get; init; }
+ public ClientSecurityToken SecurityToken { get; init; }
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
index 6d96695..dbb778f 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2022 Vaughn Nugent
+* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -35,7 +35,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Validators
{
public LoginMessageValidation()
{
- RuleFor(static t => t.ClientID)
+ RuleFor(static t => t.ClientId)
.Length(min: 10, max: 100)
.WithMessage(errorMessage: "Your browser is not sending required security information");