aboutsummaryrefslogtreecommitdiff
path: root/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints')
-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
6 files changed, 257 insertions, 167 deletions
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)