From 03f3226ea055dca3565bb859437624ef04a236fd Mon Sep 17 00:00:00 2001 From: vnugent Date: Thu, 9 Mar 2023 01:48:39 -0500 Subject: Omega cache, session, and account provider complete overhaul --- .../src/Endpoints/KeepAliveEndpoint.cs | 20 ++- .../src/Endpoints/LoginEndpoint.cs | 149 ++++++++++----------- .../src/Endpoints/LogoutEndpoint.cs | 6 +- .../src/Endpoints/MFAEndpoint.cs | 97 +++++++++----- .../src/Endpoints/PasswordResetEndpoint.cs | 144 +++++++++++++++----- .../src/Endpoints/ProfileEndpoint.cs | 8 +- 6 files changed, 257 insertions(+), 167 deletions(-) (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints') 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 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 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(); + MultiFactor = pbase.GetConfigElement(); } private class MfaUpgradeWebm : ValErrWebMessage @@ -94,7 +90,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints protected async override ValueTask 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? 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? 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 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 buffer = MemoryUtil.SafeAlloc(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 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 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(); + MultiFactor = pbase.GetConfigElement(); + Passwords = pbase.GetPasswords(); } private class TOTPUpdateMessage @@ -78,18 +79,22 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints protected override async ValueTask GetAsync(HttpEntity entity) { List 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. + */ + /// /// 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 ResetMessValidator; - public PasswordChangeEndpoint(PluginBase pbase, IReadOnlyDictionary config) + public PasswordChangeEndpoint(PluginBase pbase, IConfigScope config) { string? path = config["path"].GetString(); InitPathAndLog(path, pbase.Log); - Users = pbase.GetUserManager(); + Users = pbase.GetOrCreateSingleton(); Passwords = pbase.GetPasswords(); + ResetMessValidator = GetMessageValidator(); + mFAConfig = pbase.GetConfigElement(); + } + + private static IValidator GetMessageValidator() + { + InlineValidator 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 PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); //get the request body - using JsonDocument? request = await entity.GetJsonFromFileAsync(); + using PasswordResetMesage? pwReset = await entity.GetJsonFromFileAsync(); - 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 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(); } protected override async ValueTask GetAsync(HttpEntity entity) -- cgit