diff options
author | vman <public@vaughnnugent.com> | 2022-11-18 16:08:51 -0500 |
---|---|---|
committer | vman <public@vaughnnugent.com> | 2022-11-18 16:08:51 -0500 |
commit | 526c2364b9ad685d1c000fc8a168bf1305aaa8b7 (patch) | |
tree | a2bc01607320a6a75e1a869d5bd34e79fd63c595 /VNLib.Plugins.Essentials.Accounts/Endpoints | |
parent | 2080400119be00bdc354f3121d84ec2f89606ac7 (diff) |
Add project files.
Diffstat (limited to 'VNLib.Plugins.Essentials.Accounts/Endpoints')
6 files changed, 937 insertions, 0 deletions
diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/KeepAliveEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/KeepAliveEndpoint.cs new file mode 100644 index 0000000..eec1a33 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/KeepAliveEndpoint.cs @@ -0,0 +1,40 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Collections.Generic; + +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + [ConfigurationName("keepalive_endpoint")] + internal sealed class KeepAliveEndpoint : ProtectedWebEndpoint + { + /* + * Endpoint does not use a log, so IniPathAndLog is never called + * and path verification happens verbosly + */ + public KeepAliveEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, pbase.Log); + } + + protected override VfReturnType Get(HttpEntity entity) + { + //Return okay + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + + //Allow post to update user's credentials + protected override VfReturnType Post(HttpEntity entity) + { + //Return okay + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/LoginEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/LoginEndpoint.cs new file mode 100644 index 0000000..0518454 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/LoginEndpoint.cs @@ -0,0 +1,381 @@ +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; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Essentials.Accounts.MFA; +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.AccountManager; + + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + + /// <summary> + /// Provides an authentication endpoint for user-accounts + /// </summary> + [ConfigurationName("login_endpoint")] + internal sealed class LoginEndpoint : UnprotectedWebEndpoint + { + public const string INVALID_MESSAGE = "Please check your email or password."; + public const string LOCKED_ACCOUNT_MESSAGE = "You have been timed out, please try again later"; + public const string MFA_ERROR_MESSAGE = "Invalid or expired request."; + + private static readonly LoginMessageValidation LmValidator = new(); + + private readonly PasswordHashing Passwords; + private readonly MFAConfig? MultiFactor; + private readonly IUserManager Users; + private readonly uint MaxFailedLogins; + private readonly TimeSpan FailedCountTimeout; + + ///<inheritdoc/> + protected override ProtectionSettings EndpointProtectionSettings { get; } = new(); + + public LoginEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + FailedCountTimeout = config["failed_count_timeout_sec"].GetTimeSpan(TimeParseType.Seconds); + MaxFailedLogins = config["failed_count_max"].GetUInt32(); + + InitPathAndLog(path, pbase.Log); + + Passwords = pbase.GetPasswords(); + Users = pbase.GetUserManager(); + MultiFactor = pbase.GetMfaConfig(); + } + + private class MfaUpgradeWebm : ValErrWebMessage + { + [JsonPropertyName("pwtoken")] + public string? PasswordToken { get; set; } + + [JsonPropertyName("mfa")] + public bool? MultiFactorUpgrade { get; set; } = null; + } + + + protected async override ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + //Conflict if user is logged in + if (entity.LoginCookieMatches() || entity.TokenMatches()) + { + entity.CloseResponse(HttpStatusCode.Conflict); + return VfReturnType.VirtualSkip; + } + + //If mfa is enabled, allow processing via mfa + if (MultiFactor != null) + { + if (entity.QueryArgs.ContainsKey("mfa")) + { + return await ProcessMfaAsync(entity); + } + } + return await ProccesLoginAsync(entity); + } + + + private async ValueTask<VfReturnType> ProccesLoginAsync(HttpEntity entity) + { + MfaUpgradeWebm webm = new(); + try + { + //Make sure the id is regenerated (or upgraded if successful login) + entity.Session.RegenID(); + + using LoginMessage? loginMessage = await entity.GetJsonFromFileAsync<LoginMessage>(SR_OPTIONS); + + if (webm.Assert(loginMessage != null, "Invalid request data")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //validate the message + if (!LmValidator.Validate(loginMessage, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Time to get the user + using IUser? user = await Users.GetUserAndPassFromEmailAsync(loginMessage.UserName); + //Make sure account exists + if (webm.Assert(user != null, INVALID_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Make sure the account has not been locked out + if (webm.Assert(!UserLoginLocked(user), LOCKED_ACCOUNT_MESSAGE)) + { + goto Cleanup; + } + + //Only allow local accounts + if (user.IsLocalAccount() && !PrivateString.IsNullOrEmpty(user.PassHash)) + { + //If login return true, the response has been set and we should return + if (LoginUser(entity, loginMessage, user, webm)) + { + goto Cleanup; + } + } + + //Inc failed login count + user.FailedLoginIncrement(); + webm.Result = INVALID_MESSAGE; + + Cleanup: + await user.ReleaseAsync(); + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + catch (UserUpdateException uue) + { + Log.Warn(uue); + return VfReturnType.Error; + } + } + + private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) + { + //Verify password before we tell the user the status of their account for security reasons + if (!Passwords.Verify(user.PassHash, new PrivateString(loginMessage.Password, false))) + { + return false; + } + //Reset flc for account + user.FailedLoginCount(0); + try + { + switch (user.Status) + { + 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); + return false; + } + } + /* + * Account auhorization may throw excetpions if the configuration does not + * match the client, or the client sent invalid or malicous data and + * it could not grant authorization + */ + catch (OutOfMemoryException) + { + webm.Result = "Your browser sent malformatted security information"; + } + catch (CryptographicException ce) + { + webm.Result = "Your browser sent malformatted security information"; + Log.Debug(ce); + } + return true; + } + + + 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); + return VfReturnType.VirtualSkip; + } + + //Recover stored signature + string? storedSig = entity.Session.MfaUpgradeSignature(); + 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)) + { + webm.Result = MFA_ERROR_MESSAGE; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //recover user account + using IUser? user = await Users.GetUserFromEmailAsync(upgrade.UserName!); + + if (webm.Assert(user != null, MFA_ERROR_MESSAGE)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Wipe session signature + entity.Session.MfaUpgradeSignature(null); + + //Make sure the account has not been locked out + if (!webm.Assert(!UserLoginLocked(user), LOCKED_ACCOUNT_MESSAGE)) + { + //process mfa login + LoginMfa(entity, user, request, upgrade, webm); + } + + //Update user on clean process + await user.ReleaseAsync(); + //Close rseponse + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm) + { + //Recover the user's local time + DateTimeOffset localTime = request.RootElement.GetProperty("localtime").GetDateTimeOffset(); + + //Check mode + switch (upgrade.Type) + { + case MFAType.TOTP: + { + //get totp code from request + uint code = request.RootElement.GetProperty("code").GetUInt32(); + //Verify totp code + if (!MultiFactor!.VerifyTOTP(user, code)) + { + webm.Result = "Please check your code."; + //Increment flc and update the user in the store + user.FailedLoginIncrement(); + return; + } + //Valid, complete + } + break; + case MFAType.GPG: + { } + break; + default: + { + webm.Result = MFA_ERROR_MESSAGE; + } + return; + } + //build login message from upgrade + LoginMessage loginMessage = new() + { + 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); + //Set the password token as the password field of the login message + webm.PasswordToken = upgrade.PwClientData; + //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]); + } + + private static string EncryptSecret(string pubKey, byte[] secret) + { + //Alloc buffer for secret + using IMemoryHandle<byte> buffer = Memory.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 + TimestampedCounter flc = user.FailedLoginCount(); + if(flc.Count < MaxFailedLogins) + { + //Period exceeded + return false; + } + //See if the flc timeout period has expired + if (flc.LastModified.Add(FailedCountTimeout) < DateTimeOffset.UtcNow) + { + //clear flc flag + user.FailedLoginCount(0); + return false; + } + //Count has been exceeded, and has not timed out yet + return true; + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/LogoutEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/LogoutEndpoint.cs new file mode 100644 index 0000000..c52eef5 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/LogoutEndpoint.cs @@ -0,0 +1,34 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Collections.Generic; + +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Essentials.Endpoints; + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + [ConfigurationName("logout_endpoint")] + internal class LogoutEndpoint : ProtectedWebEndpoint + { + //Use default ep protection (most strict) + + ///<inheritdoc/> + protected override ProtectionSettings EndpointProtectionSettings { get; } = new(); + + + public LogoutEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + InitPathAndLog(path, pbase.Log); + } + + + protected override VfReturnType Post(HttpEntity entity) + { + entity.InvalidateLogin(); + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + } +} diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/MFAEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/MFAEndpoint.cs new file mode 100644 index 0000000..be6aee3 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/MFAEndpoint.cs @@ -0,0 +1,258 @@ +using System; +using System.Net; +using System.Text.Json; +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; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Accounts.MFA; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + [ConfigurationName("mfa_endpoint")] + internal sealed class MFAEndpoint : ProtectedWebEndpoint + { + public const int TOTP_URL_MAX_CHARS = 1024; + + private readonly IUserManager Users; + private readonly MFAConfig? MultiFactor; + + public MFAEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + InitPathAndLog(path, pbase.Log); + + Users = pbase.GetUserManager(); + MultiFactor = pbase.GetMfaConfig(); + } + + private class TOTPUpdateMessage + { + [JsonPropertyName("issuer")] + public string? Issuer { get; set; } + [JsonPropertyName("digits")] + public int Digits { get; set; } + [JsonPropertyName("period")] + public int Period { get; set; } + [JsonPropertyName("secret")] + public string? Base64EncSecret { get; set; } + [JsonPropertyName("algorithm")] + public string? Algorithm { get; set; } + } + + 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; + } + + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + WebMessage webm = new(); + + //Get the request message + using JsonDocument? mfaRequest = await entity.GetJsonFromFileAsync(); + if (webm.Assert(mfaRequest != null, "Invalid request")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the type argument + string? mfaType = mfaRequest.RootElement.GetPropString("type"); + if (string.IsNullOrWhiteSpace(mfaType)) + { + webm.Result = "MFA type was not specified"; + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Make sure the user's account origin is a local account + if (webm.Assert(entity.Session.HasLocalAccount(), "Your account uses external authentication and MFA cannot be enabled")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Make sure mfa is loaded + if (webm.Assert(MultiFactor != null, "MFA is not enabled on this server")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //get the user's password challenge + using (PrivateString? password = (PrivateString?)mfaRequest.RootElement.GetPropString("challenge")) + { + if (PrivateString.IsNullOrEmpty(password)) + { + webm.Result = "Please check your password"; + entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm); + return VfReturnType.VirtualSkip; + } + //Verify challenge + if (!entity.Session.VerifyChallenge(password)) + { + 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": + { + //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 + UnsafeMemoryHandle<byte> outputBuffer = Memory.UnsafeAlloc<byte>(4096, true); + try + { + //Encrypt the secret for the client + ERRNO count = entity.Session.TryEncryptClientData(secretBuffer, outputBuffer.Span); + if (!count) + { + webm.Result = "There was an error updating your credentials"; + //If this code is running, the client should have a valid public key stored, but log it anyway + Log.Warn("TOTP secret encryption failed, for requested user {uid}", entity.Session.UserID); + break; + } + webm.Result = new TOTPUpdateMessage() + { + Issuer = MultiFactor.IssuerName, + Digits = MultiFactor.TOTPDigits, + Period = (int)MultiFactor.TOTPPeriod.TotalSeconds, + Algorithm = MultiFactor.TOTPAlg.ToString(), + //Convert the secret to base64 string to send to client + Base64EncSecret = Convert.ToBase64String(outputBuffer.Span[..(int)count]) + }; + //set success flag + webm.Success = true; + } + finally + { + //dispose the output buffer + outputBuffer.Dispose(); + RandomHash.GetRandomBytes(secretBuffer); + } + //Only write changes to the db of operation was successful + await user.ReleaseAsync(); + } + break; + default: + webm.Result = "The server does not support the specified MFA type"; + break; + } + //Close response + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + WebMessage webm = new(); + try + { + //Check account type + if (!entity.Session.HasLocalAccount()) + { + webm.Result = "You are using external authentication. Operation failed."; + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //get the request + using JsonDocument? request = await entity.GetJsonFromFileAsync(); + if (webm.Assert(request != null, "Invalid request.")) + { + 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; + } + //Check for totp disable + if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase)) + { + //Clear the TOTP secret + user.MFASetTOTPSecret(null); + //write changes + await user.ReleaseAsync(); + webm.Result = "Successfully disabled your TOTP authentication"; + webm.Success = true; + } + else if ("fido".Equals(mfaType, StringComparison.OrdinalIgnoreCase)) + { + //Clear webauthn changes + + //write changes + await user.ReleaseAsync(); + webm.Result = "Successfully disabled your FIDO authentication"; + webm.Success = true; + } + else + { + webm.Result = "Invalid MFA type"; + } + //Must write response while password is in scope + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + catch (KeyNotFoundException) + { + webm.Result = "The request was is missing required fields"; + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.BadRequest; + } + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/PasswordResetEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/PasswordResetEndpoint.cs new file mode 100644 index 0000000..81bba51 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/PasswordResetEndpoint.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; + +using FluentValidation; + +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Essentials.Endpoints; + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + + /// <summary> + /// Password reset for user's that are logged in and know + /// their passwords to reset their MFA methods + /// </summary> + [ConfigurationName("password_endpoint")] + internal sealed class PasswordChangeEndpoint : ProtectedWebEndpoint + { + private readonly IUserManager Users; + private readonly PasswordHashing Passwords; + + public PasswordChangeEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + InitPathAndLog(path, pbase.Log); + + Users = pbase.GetUserManager(); + Passwords = pbase.GetPasswords(); + } + + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + //get the request body + using JsonDocument? request = await entity.GetJsonFromFileAsync(); + if (request == null) + { + 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.")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + //get the user's entry in the table + 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)) + { + webm.Result = "Please check your current password"; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + //Hash the user's new password + using PrivateString newPassHash = Passwords.Hash(newPass); + //Update the user's password + if (!await Users.UpdatePassAsync(user, newPassHash)) + { + //error + webm.Result = "Your password could not be updated"; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + 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; + } + } +} diff --git a/VNLib.Plugins.Essentials.Accounts/Endpoints/ProfileEndpoint.cs b/VNLib.Plugins.Essentials.Accounts/Endpoints/ProfileEndpoint.cs new file mode 100644 index 0000000..c0d86b6 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts/Endpoints/ProfileEndpoint.cs @@ -0,0 +1,108 @@ +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; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; +using static VNLib.Plugins.Essentials.Statics; + + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + /// <summary> + /// Provides an http endpoint for user account profile access + /// </summary> + [ConfigurationName("profile_endpoint")] + internal sealed class ProfileEndpoint : ProtectedWebEndpoint + { + private readonly IUserManager Users; + + public ProfileEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, pbase.Log); + //Store user system + Users = pbase.GetUserManager(); + } + + protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) + { + //get user data from database + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); + //Make sure the account exists + if (user == null || user.Status != UserStatus.Active) + { + //Account was not found + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + //Get the stored profile + AccountData? profile = user.GetProfile(); + //No profile found, so return an empty "profile" + profile ??= new() + { + //set email address + EmailAddress = user.EmailAddress, + //created time in rfc1123 gmt time + Created = user.Created.ToString("R") + }; + //Serialize the profile and return to user + entity.CloseResponseJson(HttpStatusCode.OK, profile); + return VfReturnType.VirtualSkip; + } + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + try + { + //Recover the update message form the client + AccountData? updateMessage = await entity.GetJsonFromFileAsync<AccountData>(SR_OPTIONS); + if (webm.Assert(updateMessage != null, "Malformatted payload")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + //Validate the new account data + if (!AccountValidations.AccountDataValidator.Validate(updateMessage, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + //Get the user from database + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); + //Make sure the user exists + if (webm.Assert(user != null, "Account does not exist")) + { + //Should probably log the user out here + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + //Overwite the current profile data (will also sanitize inputs) + user.SetProfile(updateMessage); + //Update the user only if successful + await user.ReleaseAsync(); + webm.Result = "Successfully updated account"; + webm.Success = true; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + //Catch an account update exception + catch (UserUpdateException uue) + { + Log.Error(uue, "An error occured while the user account is being updated"); + //Return message to client + webm.Result = "An error occured while updating your account, try again later"; + entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm); + return VfReturnType.VirtualSkip; + } + } + } +}
\ No newline at end of file |