diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts')
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"); |