diff options
Diffstat (limited to 'plugins')
25 files changed, 1829 insertions, 758 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/Endpoints/UsersEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/Endpoints/UsersEndpoint.cs index 66e5e1e..dcd7799 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/Endpoints/UsersEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/Endpoints/UsersEndpoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts.Admin @@ -32,10 +32,10 @@ using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Essentials.Accounts.Admin.Model; using VNLib.Plugins.Extensions.Data; +using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Sql; using VNLib.Plugins.Extensions.Loading.Users; using VNLib.Plugins.Essentials.Accounts.Admin.Helpers; -using VNLib.Plugins.Extensions.Loading; namespace VNLib.Plugins.Essentials.Accounts.Admin.Endpoints { @@ -51,7 +51,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Admin.Endpoints this.LocalOnly = plugin.LocalOnlyEnabled(); string? path = config["path"].GetString(); //Store user-manager - Manager = plugin.GetUserManager(); + Manager = plugin.GetOrCreateSingleton<UserManager>(); //Create the indirect user context store UserStore = new(plugin.GetContextOptions()); diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj index 55c0a75..32384ea 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj @@ -5,8 +5,6 @@ <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <AnalysisLevel>latest-all</AnalysisLevel> - <SignAssembly>True</SignAssembly> - <AssemblyOriginatorKeyFile>\\vaughnnugent.com\Internal\Folder Redirection\vman\Documents\Programming\Software\StrongNameingKey.snk</AssemblyOriginatorKeyFile> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs index c39165c..b593456 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts.Registration @@ -45,7 +45,7 @@ using VNLib.Plugins.Extensions.Loading.Sql; using VNLib.Plugins.Extensions.Loading.Events; using VNLib.Plugins.Extensions.Loading.Users; using VNLib.Plugins.Extensions.Validation; -using VNLib.Plugins.Extentions.TransactionalEmail; +using Emails.Transactional.Client.Extensions; using VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation; using static VNLib.Plugins.Essentials.Accounts.AccountUtil; @@ -54,7 +54,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints { [ConfigurationName("registration")] - internal sealed class RegistrationEntpoint : UnprotectedWebEndpoint, IIntervalScheduleable + internal sealed class RegistrationEntpoint : UnprotectedWebEndpoint { /// <summary> /// Generates a CNG random buffer to use as a nonce @@ -66,7 +66,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints private readonly IUserManager Users; private readonly IValidator<string> RegJwtValdidator; - private readonly PasswordHashing Passwords; + private readonly IPasswordHashingProvider Passwords; private readonly RevokedTokenStore RevokedTokens; private readonly TransactionalEmailConfig Emails; private readonly Task<ReadOnlyJsonWebKey> RegSignatureKey; @@ -77,7 +77,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints /// </summary> /// <param name="Path">The path identifier</param> /// <exception cref="ArgumentException"></exception> - public RegistrationEntpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) + public RegistrationEntpoint(PluginBase plugin, IConfigScope config) { string? path = config["path"].GetString(); @@ -89,15 +89,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints RegJwtValdidator = GetJwtValidator(); Passwords = plugin.GetPasswords(); - Users = plugin.GetUserManager(); + Users = plugin.GetOrCreateSingleton<UserManager>(); RevokedTokens = new(plugin.GetContextOptions()); - Emails = plugin.GetEmailConfig(); + Emails = plugin.GetOrCreateSingleton<TEmailConfig>(); //Begin the async op to get the signature key from the vault RegSignatureKey = plugin.TryGetSecretAsync("reg_sig_key").ToJsonWebKey(true); - - //Register timeout for cleanup - plugin.ScheduleInterval(this, TimeSpan.FromSeconds(60)); } private static IValidator<string> GetJwtValidator() @@ -300,7 +297,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints string regUrl = $"https://{entity.Server.RequestUri.Authority}{Path}?t={jwtData}"; //Send email to user in background task and do not await it - _ = SendRegEmailAsync(request.UserName!, regUrl).ConfigureAwait(false); + _ = SendRegEmailAsync(request.UserName!, regUrl, timeStamp).ConfigureAwait(false); Exit: //await sort of constant time delay @@ -315,7 +312,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints } - private async Task SendRegEmailAsync(string emailAddress, string url) + private async Task SendRegEmailAsync(string emailAddress, string url, DateTimeOffset current) { try { @@ -326,7 +323,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints emailTemplate.AddVariable("username", emailAddress); //Set the security code variable string emailTemplate.AddVariable("reg_url", url); - emailTemplate.AddVariable("date", DateTimeOffset.UtcNow.ToString("f")); + emailTemplate.AddVariable("date", current.ToString("f")); //Send the email TransactionResult result = await Emails.SendEmailAsync(emailTemplate); @@ -359,7 +356,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints } } - async Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) + + //Schedule cleanup interval 60 seconds + [AsyncInterval(Minutes = 1)] + public async Task OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) { //Cleanup tokens await RevokedTokens.CleanTableAsync(RegExpiresSec, cancellationToken); diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs index b066a16..bceb5e1 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs @@ -36,11 +36,11 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration protected override void OnLoad() { - //Route reg endpoint - this.Route<RegistrationEntpoint>(); + //Route reg endpoint + this.Route<RegistrationEntpoint>(); - Log.Information("Plugin loaded"); - } + Log.Information("Plugin loaded"); + } protected override void OnUnLoad() { 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"); diff --git a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/PageRouterEntry.cs b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/PageRouterEntry.cs index 10b7075..0c04047 100644 --- a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/PageRouterEntry.cs +++ b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/PageRouterEntry.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Content.Routing @@ -23,32 +23,33 @@ */ using System; -using System.Threading.Tasks; -using System.Collections.Generic; +using System.ComponentModel.Design; using VNLib.Utils.Logging; +using VNLib.Plugins.Attributes; +using VNLib.Plugins.Extensions.Loading; + namespace VNLib.Plugins.Essentials.Content.Routing { - public sealed class PageRouterEntry : PluginBase, IPageRouter + public sealed class PageRouterEntry : PluginBase { public override string PluginName => "Essentials.Router"; private Router PageRouter; - public ValueTask<FileProcessArgs> RouteAsync(HttpEntity entity) => PageRouter.RouteAsync(entity); + + [ServiceConfigurator] + public void ConfigureServices(IServiceContainer services) + { + //Deploy the page router to the host + services.AddService(typeof(IPageRouter), PageRouter); + } protected override void OnLoad() { - try - { - //Init router - PageRouter = new(this); - Log.Information("Plugin loaded"); - } - catch (KeyNotFoundException knf) - { - Log.Error("Plugin failed to load, missing required configuration variables {err}", knf.Message); - } + //Init router + PageRouter = this.GetOrCreateSingleton<Router>(); + Log.Information("Plugin loaded"); } protected override void OnUnLoad() diff --git a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/Router.cs b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/Router.cs index 7620809..31f1e0e 100644 --- a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/Router.cs +++ b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/Router.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Content.Routing @@ -25,33 +25,32 @@ using System; using System.Linq; using System.Buffers; -using System.Threading; using System.Threading.Tasks; using System.Collections.Generic; using System.Collections.Concurrent; using System.Collections.ObjectModel; -using VNLib.Net.Http; -using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Sql; -using VNLib.Plugins.Extensions.Loading.Events; using VNLib.Plugins.Essentials.Content.Routing.Model; using static VNLib.Plugins.Essentials.Accounts.AccountUtil; + namespace VNLib.Plugins.Essentials.Content.Routing { - internal class Router : IPageRouter, IIntervalScheduleable + [ConfigurationName("page_router", Required = false)] + internal class Router : IPageRouter { private static readonly RouteComparer Comparer = new(); private readonly RouteStore Store; - private readonly ConcurrentDictionary<IWebRoot, Task<ReadOnlyCollection<Route>>> RouteTable; + private readonly ConcurrentDictionary<IWebProcessor, Task<ReadOnlyCollection<Route>>> RouteTable; public Router(PluginBase plugin) { Store = new(plugin.GetContextOptions()); - plugin.ScheduleInterval(this, TimeSpan.FromSeconds(30)); RouteTable = new(); } @@ -59,11 +58,13 @@ namespace VNLib.Plugins.Essentials.Content.Routing public async ValueTask<FileProcessArgs> RouteAsync(HttpEntity entity) { ulong privilage = READ_MSK; - //Only select privilages for logged-in users - if (entity.Session.IsSet && entity.LoginCookieMatches() || entity.TokenMatches()) + + //Only select privilages for logged-in users, this is a medium security check since we may not have all data available + if (entity.Session.IsSet && entity.IsClientAuthorized(AuthorzationCheckLevel.Medium)) { privilage = entity.Session.Privilages; } + //Get the routing table for the current host ReadOnlyCollection<Route> routes = await RouteTable.GetOrAdd(entity.RequestedRoot, LoadRoutesAsync); //Find the proper routine for the connection @@ -75,7 +76,7 @@ namespace VNLib.Plugins.Essentials.Content.Routing /// </summary> public void ResetRoutes() => RouteTable.Clear(); - private async Task<ReadOnlyCollection<Route>> LoadRoutesAsync(IWebRoot root) + private async Task<ReadOnlyCollection<Route>> LoadRoutesAsync(IWebProcessor root) { List<Route> collection = new(); //Load all routes @@ -152,10 +153,5 @@ namespace VNLib.Plugins.Essentials.Content.Routing //Test if the level and group privilages match for the current routine return (privilages & LEVEL_MSK) >= (route.Privilage & LEVEL_MSK) && (route.Privilage & GROUP_MSK) == (privilages & GROUP_MSK); } - - Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs index 8a7aea3..18f4081 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs @@ -1,12 +1,12 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth -* File: ClientAccessTokenState.cs +* File: OAuthAccessState.cs * -* ClientAccessTokenState.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger -* VNLib collection of libraries and utilities. +* OAuthAccessState.cs is part of VNLib.Plugins.Essentials.SocialOauth which +* is part of the larger VNLib collection of libraries and utilities. * * VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -23,17 +23,11 @@ */ using System; -using System.Security.Cryptography; using System.Text.Json.Serialization; -using VNLib.Hashing; -using VNLib.Utils.Memory; -using VNLib.Utils.Memory.Caching; -using VNLib.Plugins.Essentials.Accounts; - namespace VNLib.Plugins.Essentials.SocialOauth { - public sealed class OAuthAccessState : IOAuthAccessState, ICacheable, INonce + public sealed class OAuthAccessState : IOAuthAccessState { ///<inheritdoc/> [JsonPropertyName("access_token")] @@ -50,36 +44,5 @@ namespace VNLib.Plugins.Essentials.SocialOauth ///<inheritdoc/> [JsonPropertyName("id_token")] public string? IdToken { get; set; } - - //Ignore the public key and client ids - [JsonIgnore] - internal string? PublicKey { get; set; } - [JsonIgnore] - internal string? ClientId { get; set; } - - /// <summary> - /// A random nonce generated when the access state is created and - /// deleted when then access token is evicted. - /// </summary> - [JsonIgnore] - internal ReadOnlyMemory<byte> Nonce { get; private set; } - - DateTime ICacheable.Expires { get; set; } - bool IEquatable<ICacheable>.Equals(ICacheable? other) => GetHashCode() == other?.GetHashCode(); - public override int GetHashCode() => Token!.GetHashCode(StringComparison.Ordinal); - void ICacheable.Evicted() - { - MemoryUtil.UnsafeZeroMemory(Nonce); - } - - void INonce.ComputeNonce(Span<byte> buffer) - { - //Compute nonce - RandomHash.GetRandomBytes(buffer); - //Copy and store - Nonce = buffer.ToArray(); - } - - bool INonce.VerifyNonce(ReadOnlySpan<byte> nonceBytes) => CryptographicOperations.FixedTimeEquals(Nonce.Span, nonceBytes); } }
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs index 3466ad0..2f99693 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -24,7 +24,6 @@ using System; using System.Linq; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -32,13 +31,13 @@ using System.Collections.Generic; using RestSharp; -using VNLib.Net.Rest.Client; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Utils.Logging; +using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; + namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { @@ -46,13 +45,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints [ConfigurationName("auth0")] internal sealed class Auth0 : SocialOauthBase { - - protected override OauthClientConfig Config { get; } - - private readonly Task<ReadOnlyJsonWebKey[]> Auth0VerificationJwk; - public Auth0(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base() + public Auth0(PluginBase plugin, IConfigScope config) : base(plugin, config) { string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); @@ -60,31 +55,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints //Get certificate on background thread Auth0VerificationJwk = Task.Run(() => GetRsaCertificate(keyUri)); - - Config = new("auth0", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> secretTask = plugin.TryGetSecretAsync("auth0_client_secret"); - Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync("auth0_client_id"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Auth0 client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Auth0 client secret from config or vault"); - - }, 100); } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs index a701bdf..93cb22d 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -25,9 +25,7 @@ using System; using System.Text; using System.Threading; -using System.Text.Json; using System.Threading.Tasks; -using System.Collections.Generic; using System.Text.Json.Serialization; using RestSharp; @@ -37,41 +35,14 @@ using VNLib.Utils.Logging; using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { [ConfigurationName("discord")] internal sealed class DiscordOauth : SocialOauthBase { - protected override OauthClientConfig Config { get; } - - public DiscordOauth(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base() + public DiscordOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) { - Config = new("discord", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync("discord_client_id"); - Task<SecretResult?> secretTask = plugin.TryGetSecretAsync("discord_client_secret"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Discord client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Discord client secret from config or vault"); - - }, 100); } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs index 7e8c576..3d6fa30 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,9 +23,7 @@ */ using System; -using System.Text; using System.Threading; -using System.Text.Json; using System.Threading.Tasks; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -37,7 +35,6 @@ using VNLib.Utils.Logging; using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { @@ -47,38 +44,11 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints private const string GITHUB_V3_ACCEPT = "application/vnd.github.v3+json"; private readonly string UserEmailUrl; - - protected override OauthClientConfig Config { get; } + - public GitHubOauth(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base() - { - + public GitHubOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) + { UserEmailUrl = config["user_email_url"].GetString() ?? throw new KeyNotFoundException("Missing required key 'user_email_url' for github configuration"); - - Config = new("github", config) - { - Passwords = plugin.GetPasswords(), - Users = plugin.GetUserManager(), - }; - - InitPathAndLog(Config.EndpointPath, plugin.Log); - - //Load secrets - _ = plugin.ObserveTask(async () => - { - //Get id/secret - Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync("github_client_id"); - Task<SecretResult?> secretTask = plugin.TryGetSecretAsync("github_client_secret"); - - await Task.WhenAll(secretTask, clientIdTask); - - using SecretResult? secret = await secretTask; - using SecretResult? clientId = await clientIdTask; - - Config.ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException("Missing Github client id from config or vault"); - Config.ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException("Missing the Github client secret from config or vault"); - - }, 100); } protected override void StaticClientPoolInitializer(RestClient client) diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs index 9caf705..5b0d8ed 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,31 +23,36 @@ */ using System; -using System.Text.Json; +using System.Threading.Tasks; using System.Collections.Generic; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Accounts; - +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; namespace VNLib.Plugins.Essentials.SocialOauth { - public sealed class OauthClientConfig + public sealed class OauthClientConfig : IAsyncConfigurable { + private readonly string ConfigName; + - public OauthClientConfig(string configName, IReadOnlyDictionary<string, JsonElement> config) + public OauthClientConfig(PluginBase plugin, IConfigScope config) { - EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {configName}"); + ConfigName = config.ScopeName; + + EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {ConfigName}"); //Set discord account origin - AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {configName}"); + AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {ConfigName}"); //Get the auth and token urls - string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {configName}"); - string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {configName}"); - string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {configName}"); + string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {ConfigName}"); + string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {ConfigName}"); + string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {ConfigName}"); //Create the uris AccessCodeUrl = new(authUrl); AccessTokenUrl = new(tokenUrl); @@ -58,12 +63,31 @@ namespace VNLib.Plugins.Essentials.SocialOauth LoginNonceLifetime = config["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); NonceByteSize = config["nonce_size"].GetUInt32(); RandomPasswordSize = config["password_size"].GetInt32(); + InitClaimValidFor = config["claim_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + + Users = plugin.GetOrCreateSingleton<UserManager>(); + Passwords = plugin.GetPasswords(); } - - public string ClientID { get; set; } = string.Empty; + public async Task ConfigureServiceAsync(PluginBase plugin) + { + //Get id/secret + Task<SecretResult?> clientIdTask = plugin.TryGetSecretAsync($"{ConfigName}_client_id"); + Task<SecretResult?> secretTask = plugin.TryGetSecretAsync($"{ConfigName}_client_secret"); + + await Task.WhenAll(secretTask, clientIdTask); + + using SecretResult? secret = await secretTask; + using SecretResult? clientId = await clientIdTask; + + ClientID = clientId?.Result.ToString() ?? throw new KeyNotFoundException($"Missing {ConfigName} client id from config or vault"); + ClientSecret = secret?.Result.ToString() ?? throw new KeyNotFoundException($"Missing the {ConfigName} client secret from config or vault"); + } + + + public string ClientID { get; private set; } = string.Empty; - public string ClientSecret { get; set; } = string.Empty; + public string ClientSecret { get; private set; } = string.Empty; /// <summary> @@ -92,9 +116,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <summary> /// The user store to create/get users from /// </summary> - public IUserManager Users { get; init; } + public IUserManager Users { get; } - public PasswordHashing Passwords { get; init; } + public IPasswordHashingProvider Passwords { get; } /// <summary> /// The endpoint route/path @@ -122,5 +146,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// The size (in bytes) of the random password generated for new users /// </summary> public int RandomPasswordSize { get; } + + /// <summary> + /// The initial time the login claim is valid for + /// </summary> + public TimeSpan InitClaimValidFor { get; } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs index d0f7a84..05152b2 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -23,7 +23,6 @@ */ using System; -using System.Collections.Generic; using VNLib.Utils.Logging; using VNLib.Plugins.Essentials.SocialOauth.Endpoints; @@ -39,32 +38,25 @@ namespace VNLib.Plugins.Essentials.SocialOauth protected override void OnLoad() { - try + //Get the discord oauth config from the config file + if (this.HasConfigForType<DiscordOauth>()) { - //Get the discord oauth config from the config file - if (this.HasConfigForType<DiscordOauth>()) - { - //Add the discord login endpoint - this.Route<DiscordOauth>(); - Log.Information("Discord social OAuth authentication loaded"); - } - if (this.HasConfigForType<GitHubOauth>()) - { - //Add the github login endpoint - this.Route<GitHubOauth>(); - Log.Information("Github social OAuth authentication loaded"); - } - - if (this.HasConfigForType<Auth0>()) - { - //Add the auth0 login endpoint - this.Route<Auth0>(); - Log.Information("Auth0 social OAuth authentication loaded"); - } + //Add the discord login endpoint + this.Route<DiscordOauth>(); + Log.Information("Discord social OAuth authentication loaded"); + } + if (this.HasConfigForType<GitHubOauth>()) + { + //Add the github login endpoint + this.Route<GitHubOauth>(); + Log.Information("Github social OAuth authentication loaded"); } - catch(KeyNotFoundException kne) + + if (this.HasConfigForType<Auth0>()) { - Log.Error("Missing required configuration variables, {reason}", kne.Message); + //Add the auth0 login endpoint + this.Route<Auth0>(); + Log.Information("Auth0 social OAuth authentication loaded"); } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs index 73c2ab5..2ad3a8e 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -25,6 +25,7 @@ using System; using System.Net; using System.Text; +using System.Buffers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,7 @@ using System.Collections.Generic; using System.Security.Cryptography; using System.Text.Json.Serialization; using System.Runtime.InteropServices; +using System.Diagnostics.CodeAnalysis; using FluentValidation; @@ -39,17 +41,18 @@ using RestSharp; using VNLib.Net.Http; using VNLib.Net.Rest.Client; using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; -using VNLib.Utils.Memory.Caching; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Essentials.Endpoints; using VNLib.Plugins.Essentials.Extensions; -using VNLib.Plugins.Extensions.Validation; using VNLib.Plugins.Essentials.SocialOauth.Validators; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; namespace VNLib.Plugins.Essentials.SocialOauth { @@ -62,11 +65,17 @@ namespace VNLib.Plugins.Essentials.SocialOauth const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; const string AUTH_GRANT_SESSION_NAME = "auth"; + const string SESSION_SIG_KEY_NAME = "soa.sig"; + const string SESSION_TOKEN_KEY_NAME = "soa.tkn"; + const string CLAIM_COOKIE_NAME = "extern-claim"; + const int SIGNING_KEY_SIZE = 32; + + private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key); /// <summary> /// The client configuration struct passed during base class construction /// </summary> - protected abstract OauthClientConfig Config { get; } + protected virtual OauthClientConfig Config { get; } ///<inheritdoc/> protected override ProtectionSettings EndpointProtectionSettings { get; } = new() @@ -82,17 +91,13 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// The resst client connection pool /// </summary> protected RestClientPool ClientPool { get; } - - private readonly Dictionary<string, LoginClaim> ClaimStore; - private readonly Dictionary<string, OAuthAccessState> AuthorizationStore; + private readonly IValidator<LoginClaim> ClaimValidator; private readonly IValidator<string> NonceValidator; private readonly IValidator<AccountData> AccountDataValidator; - protected SocialOauthBase() + protected SocialOauthBase(PluginBase plugin, IConfigScope config) { - ClaimStore = new(StringComparer.OrdinalIgnoreCase); - AuthorizationStore = new(StringComparer.OrdinalIgnoreCase); ClaimValidator = GetClaimValidator(); NonceValidator = GetNonceValidator(); AccountDataValidator = new AccountDataValidator(); @@ -108,6 +113,12 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Configure rest client to comunications to main discord api ClientPool = new(10, poolOptions, StaticClientPoolInitializer); + + //Get the configuration element for the derrived type + Config = plugin.CreateService<OauthClientConfig>(config); + + //Init endpoint + InitPathAndLog(Config.EndpointPath, plugin.Log); } private static IValidator<LoginClaim> GetClaimValidator() @@ -133,6 +144,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth return val; } + ///<inheritdoc/> protected override ERRNO PreProccess(HttpEntity entity) { if (!base.PreProccess(entity)) @@ -150,7 +162,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth } //Make sure the user is not logged in - return !(entity.LoginCookieMatches() || entity.TokenMatches()); + return !entity.IsClientAuthorized(AuthorzationCheckLevel.Any); } /// <summary> @@ -176,8 +188,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// A task the resolves the <see cref="OAuthAccessState"/> that includes all relavent /// authorization data. Result may be null if authorzation is invalid or not granted /// </returns> - /// <param name="cancellationToken"></param> - protected async Task<OAuthAccessState?> ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) + protected virtual async Task<OAuthAccessState?> ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) { //valid response, time to get the actual authorization from gh for client RestRequest request = new(Config.AccessTokenUrl, Method.Post); @@ -207,7 +218,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <param name="clientAccess">The access state from the code/token exchange</param> /// <param name="cancellationToken">A token to cancel the operation</param> /// <returns>The user's account data, null if not account exsits on the remote site, and process cannot continue</returns> - /// <param name="cancellationToken"></param> protected abstract Task<AccountData?> GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken); /// <summary> /// Gets an object that represents the required information for logging-in a user (namley unique user-id) @@ -217,45 +227,94 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <returns></returns> protected abstract Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); - sealed class LoginClaim : ICacheable, INonce + sealed class LoginClaim : IClientSecInfo { [JsonPropertyName("public_key")] public string? PublicKey { get; set; } + [JsonPropertyName("browser_id")] public string? ClientId { get; set; } - /// <summary> - /// The raw OAuth flow state parameter the client must decrypt before - /// navigating to remote authentication source - /// </summary> - [JsonIgnore] - public ReadOnlyMemory<byte> RawNonce { get; private set; } - [JsonIgnore] - DateTime ICacheable.Expires { get; set; } - bool IEquatable<ICacheable>.Equals(ICacheable? other) => Equals(other); - void ICacheable.Evicted() - { - //Erase nonce - MemoryUtil.UnsafeZeroMemory(RawNonce); - } + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } - public override bool Equals(object? obj) + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + public void ComputeNonce(int nonceSize) { - return obj is LoginClaim otherClaim && this.PublicKey!.Equals(otherClaim.PublicKey, StringComparison.Ordinal); + byte[] buffer = ArrayPool<byte>.Shared.Rent(nonceSize); + try + { + Span<byte> nonce = buffer.AsSpan(0, nonceSize); + + //get random data + RandomHash.GetRandomBytes(nonce); + + //Encode nonce + Nonce = VnEncoding.ToBase32String(nonce); + } + finally + { + MemoryUtil.InitializeBlock(buffer.AsSpan()); + ArrayPool<byte>.Shared.Return(buffer); + } } - public override int GetHashCode() => PublicKey!.GetHashCode(); + } + + /* + * Claims are considered indempodent because they require no previous state + * and will return a new secret authentication "token" (url + nonce) that + * uniquely identifies the claim and authorization upgrade later + */ + + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); - void INonce.ComputeNonce(Span<byte> buffer) + //Get the login message + LoginClaim? claim = await entity.GetJsonFromFileAsync<LoginClaim>(); + + if (webm.Assert(claim != null, "Emtpy message body")) { - RandomHash.GetRandomBytes(buffer); - //Store copy - RawNonce = buffer.ToArray(); + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; } - bool INonce.VerifyNonce(ReadOnlySpan<byte> nonceBytes) + //Validate the message + if (!ClaimValidator.Validate(claim, webm)) { - return CryptographicOperations.FixedTimeEquals(RawNonce.Span, nonceBytes); + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; } + + //Configure the login claim + claim.IssuedAtTime = entity.RequestedTimeUtc.ToUnixTimeSeconds(); + + //Set expiration time in seconds + claim.ExpirationSeconds = entity.RequestedTimeUtc.Add(Config.InitClaimValidFor).ToUnixTimeMilliseconds(); + + //Set nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Build the redirect uri + webm.Result = new LoginUriBuilder() + .WithEncoding(entity.Server.Encoding) + .WithUrl(entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, Path) + .WithNonce(claim.Nonce!) + .Build(Config) + .Encrypt(entity, claim); + + //Sign and set the claim cookie + SignAndSetCookie(entity, claim); + + webm.Success = true; + //Response + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; } /* @@ -272,65 +331,61 @@ namespace VNLib.Plugins.Essentials.SocialOauth { //Disable refer headers when nonce is set entity.Server.Headers["Referrer-Policy"] = "no-referrer"; - + //Check for security navigation headers. This should be a browser redirect, if (!entity.Server.IsNavigation() || !entity.Server.IsUserInvoked()) { + ClearClaimData(entity); //The connection was not a browser redirect entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec"); return VfReturnType.VirtualSkip; } - + //Try to get the claim from the state parameter - if (ClaimStore.TryGetOrEvictRecord(state, out LoginClaim? claim) < 1) + if (!VerifyAndGetClaim(entity, out LoginClaim? claim)) { + ClearClaimData(entity); entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired"); return VfReturnType.VirtualSkip; } - - //Lock on the claim to prevent replay - lock (claim) - { - bool isValid = claim.VerifyNonce(state); - //Evict the record inside the lock, also wipes nonce contents - ClaimStore.EvictRecord(state); - //Compare binary values of nonce incase of dicionary collision - if (!isValid) - { - entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); - return VfReturnType.VirtualSkip; - } + //Confirm the nonce matches the claim + if (string.CompareOrdinal(claim.Nonce, state) != 0) + { + ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); + return VfReturnType.VirtualSkip; } - + //Exchange the OAuth code for a token (application specific) OAuthAccessState? token = await ExchangeCodeForTokenAsync(entity, code, entity.EventCancellation); - + //Token may be null - if(token == null) + if (token == null) { + ClearClaimData(entity); entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); return VfReturnType.VirtualSkip; } - - //Store claim info - token.PublicKey = claim.PublicKey; - token.ClientId = claim.ClientId; - - //Generate the new nonce - string nonce = token.ComputeNonce((int)Config.NonceByteSize); - //Collect expired records - AuthorizationStore.CollectRecords(); - //Register the access token - AuthorizationStore.StoreRecord(nonce, token, Config.LoginNonceLifetime); + + //Create the new nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Store access state in the user's session + entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token); + + //Sign and set cookie + SignAndSetCookie(entity, claim); + //Prepare redirect - entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={nonce}"); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}"); return VfReturnType.VirtualSkip; } //Check to see if there was an error code set if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) { + ClearClaimData(entity); Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]); entity.Redirect(RedirectType.Temporary, $"{Path}?result=error"); return VfReturnType.VirtualSkip; @@ -358,6 +413,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Recover the nonce string? base32Nonce = request.RootElement.GetPropString("nonce"); + if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required")) { entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); @@ -370,29 +426,30 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); return VfReturnType.VirtualSkip; } - + //Recover the access token - if (AuthorizationStore.TryGetOrEvictRecord(base32Nonce!, out OAuthAccessState? token) < 1) + bool cookieValid = VerifyAndGetClaim(entity, out LoginClaim? claim); + + if (webm.Assert(cookieValid, AUTH_ERROR_MESSAGE)) { - webm.Result = AUTH_ERROR_MESSAGE; entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } - - bool valid; - //Valid token, now verify the nonce within the locked context - lock (token) - { - valid = token.VerifyNonce(base32Nonce); - //Evict (wipes nonce) - AuthorizationStore.EvictRecord(base32Nonce!); - } - - if (webm.Assert(valid, AUTH_ERROR_MESSAGE)) + + //We can clear the client's access claim + ClearClaimData(entity); + + //Confirm nonce matches the client's nonce string + bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0; + + if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE)) { entity.CloseResponse(webm); return VfReturnType.VirtualSkip; - } + } + + //Safe to recover the access token + IOAuthAccessState token = entity.Session.GetObject<OAuthAccessState>(SESSION_TOKEN_KEY_NAME); //get the user's login information (ie userid) UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation); @@ -430,12 +487,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } - //Create new user, create random passwords - byte[] randomPass = RandomHash.GetRandomBytes(Config.RandomPasswordSize); + //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future - PrivateString passhash = Config.Passwords.Hash(randomPass); - //overwite the password bytes - MemoryUtil.InitializeBlock(randomPass.AsSpan()); + using PrivateString passhash = Config.Passwords.GetRandomPassword(Config.RandomPasswordSize); try { //Create the user with the specified email address, minimum privilage level, and an empty password @@ -454,10 +508,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } - finally - { - passhash.Dispose(); - } } else { @@ -467,12 +517,14 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } + //Make sure local accounts are allowed if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) { entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } + //Reactivate inactive accounts if(user.Status == UserStatus.Inactive) { @@ -490,14 +542,17 @@ namespace VNLib.Plugins.Essentials.SocialOauth try { //Generate authoization - webm.Token = entity.GenerateAuthorization(token.PublicKey!, token.ClientId!, user); + entity.GenerateAuthorization(claim, user, webm); + //Store the user current oauth information in the current session for others to digest entity.Session.SetObject($"{Config.AccountOrigin}.{AUTH_GRANT_SESSION_NAME}", token); + //Send the username back to the client webm.Result = new AccountData() { EmailAddress = user.EmailAddress, }; + //Set the success flag webm.Success = true; //Write to log @@ -520,7 +575,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth webm.Token = null; webm.Result = AUTH_ERROR_MESSAGE; webm.Success = false; + + //destroy any login data on failure entity.InvalidateLogin(); + Log.Error(uue); } finally @@ -530,52 +588,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } + + /* - * Claims are considered indempodent because they require no previous state - * and will return a new secret authentication "token" (url + nonce) that - * uniquely identifies the claim and authorization upgrade later - */ - - protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) - { - ValErrWebMessage webm = new(); - - //Get the login message - LoginClaim? claim = await entity.GetJsonFromFileAsync<LoginClaim>(); - - if (webm.Assert(claim != null, "Emtpy message body")) - { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; - } - - //Validate the message - if (!ClaimValidator.Validate(claim, webm)) - { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; - } - - //Cleanup old records - ClaimStore.CollectRecords(); - - //Set nonce - string base32Nonce = claim.ComputeNonce((int)Config.NonceByteSize); - - //build the redirect url - webm.Result = BuildUrl(base32Nonce, claim.PublicKey!, entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, entity.Server.Encoding); - - //Store the claim - ClaimStore.StoreRecord(base32Nonce, claim, Config.LoginNonceLifetime); - - webm.Success = true; - //Response - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - /* * Construct the client's redirect url based on their login claim, which contains * a public key which can be used to encrypt the url so that only the client * private-key holder can decrypt the url and redirect themselves to the @@ -584,52 +600,199 @@ namespace VNLib.Plugins.Essentials.SocialOauth * The result is an encrypted nonce that should guard against replay attacks and MITM */ - private string BuildUrl(string base32Nonce, string pubKey, ReadOnlySpan<char> scheme, ReadOnlySpan<char> redirectAuthority, Encoding enc) + sealed class LoginUriBuilder { - //Char buffer for base32 and url building - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc<byte>(8192, true); - //get bin buffer slice - Span<byte> binBuffer = buffer.Span[1024..]; - Span<byte> charBuffer = buffer.Span[..1024]; - - ReadOnlySpan<char> url; + private readonly IMemoryHandle<byte> _buffer; + + private Span<byte> _binBuffer => _buffer.Span[1024..]; + private Span<char> _charBuffer => MemoryMarshal.Cast<byte, char>(_buffer.Span[..1024]); + + private string? redirectUrl; + private string? nonce; + private Encoding _encoding; + + private int _urlCharPointer; + + public LoginUriBuilder() + { + //Alloc buffer + _buffer = MemoryUtil.SafeAllocNearestPage<byte>(8000, true); + + //Set default encoding + _encoding = Encoding.UTF8; + } + + public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) { - //Get char buffer slice and cast to char - Span<char> charBuf = MemoryMarshal.Cast<byte, char>(charBuffer); //buffer writer for easier syntax - ForwardOnlyWriter<char> writer = new(charBuf); + ForwardOnlyWriter<char> writer = new(_charBuffer); //first build the redirect url to re-encode it writer.Append(scheme); writer.Append("://"); //Create redirect url (current page, default action is to authorize the client) - writer.Append(redirectAuthority); - writer.Append(Path); + writer.Append(authority); + writer.Append(path); //url encode the redirect path and save it for later - string redirectFiltered = Uri.EscapeDataString(writer.ToString()); - //reset the writer again to begin building the path - writer.Reset(); + redirectUrl = Uri.EscapeDataString(writer.ToString()); + + return this; + } + + public LoginUriBuilder WithEncoding(Encoding encoding) + { + _encoding = encoding; + return this; + } + + public LoginUriBuilder WithNonce(string base32Nonce) + { + nonce = base32Nonce; + return this; + } + + public LoginUriBuilder Build(OauthClientConfig config) + { + //buffer writer for easier syntax + ForwardOnlyWriter<char> writer = new(_charBuffer); + //Append the config redirect path - writer.Append(Config.AccessCodeUrl.OriginalString); + writer.Append(config.AccessCodeUrl.OriginalString); //begin query arguments writer.Append("&client_id="); - writer.Append(Config.ClientID); + writer.Append(config.ClientID); //add the redirect url writer.Append("&redirect_uri="); - writer.Append(redirectFiltered); + writer.Append(redirectUrl); //Append the state parameter writer.Append("&state="); - writer.Append(base32Nonce); - url = writer.AsSpan(); + writer.Append(nonce); + + //Update url pointer + _urlCharPointer = writer.Written; + + return this; } - //Separate buffers - Span<byte> encryptionBuffer = binBuffer[1024..]; - Span<byte> encodingBuffer = binBuffer[..1024]; - //Encode the url to binary - int byteCount = enc.GetBytes(url, encodingBuffer); - //Encrypt the binary - ERRNO count = AccountUtil.TryEncryptClientData(pubKey, encodingBuffer[..byteCount], in encryptionBuffer); - //base64 encode the encrypted - return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + + public string Encrypt(HttpEntity client, IClientSecInfo secInfo) + { + try + { + ReadOnlySpan<char> url = _charBuffer[.._urlCharPointer]; + + //Separate buffers + Span<byte> encryptionBuffer = _binBuffer[1024..]; + Span<byte> encodingBuffer = _binBuffer[..1024]; + + //Encode the url to binary + int byteCount = _encoding.GetBytes(url, encodingBuffer); + + //Encrypt the binary data + ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); + + //base64 encode the encrypted + return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + } + finally + { + _urlCharPointer = 0; + //Dispose buffer + _buffer.Dispose(); + } + } + + } + + private static bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) + { + claim = null; + + //Try to get the cookie + if(!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue)) + { + return false; + } + + //Recover the signing key from the user's session + string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; + byte[]? key = VnEncoding.FromBase32String(sigKey); + + if (key == null) + { + return false; + } + + try + { + //Try to parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); + + //Verify the jwt + using(HMAC alg = GetSigningAlg(key)) + { + if (!jwt.Verify(alg)) + { + return false; + } + } + + //Recover the clam from the jwt + claim = jwt.GetPayload<LoginClaim>(); + + //Verify the expiration time + return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); + } + catch (FormatException) + { + return false; + } + finally + { + MemoryUtil.InitializeBlock(key.AsSpan()); + } + } + + private static void ClearClaimData(HttpEntity entity) + { + if (entity.Server.RequestCookies.ContainsKey(CLAIM_COOKIE_NAME)) + { + entity.Server.ExpireCookie(CLAIM_COOKIE_NAME); + } + + entity.Session[SESSION_SIG_KEY_NAME] = null!; + } + + private void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + { + //Setup Jwt + using JsonWebToken jwt = new(); + + //Write claim body + jwt.WritePayload(claim); + + //Generate signing key + byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); + + //Sign the jwt + using(HMAC alg = GetSigningAlg(sigKey)) + { + jwt.Sign(alg); + } + + //Build and set cookie + HttpCookie cookie = new(CLAIM_COOKIE_NAME, jwt.Compile()) + { + Secure = true, + HttpOnly = true, + ValidFor = Config.InitClaimValidFor, + SameSite = CookieSameSite.SameSite + }; + + entity.Server.SetCookie(in cookie); + + //Encode and store the signing key in the clien't session + entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase32String(sigKey); + + MemoryUtil.InitializeBlock(sigKey.AsSpan()); } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs index 86893c5..3cf4e70 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -40,12 +40,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators */ public LoginMessageValidation() { - RuleFor(t => t.ClientID) + RuleFor(t => t.ClientId) .Length(10, 50) - .WithMessage("Your browser is not sending required security information"); + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() + .WithMessage("Your browser is not sending required security information"); RuleFor(t => t.ClientPublicKey) - .NotEmpty() + .Length (50, 1000) + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() .WithMessage("Your browser is not sending required security information"); //Password is only used for nonce tokens @@ -53,8 +57,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators RuleFor(t => t.LocalLanguage) .NotEmpty() + .WithMessage("Your language is not supported") + .AlphaNumericOnly() .WithMessage("Your language is not supported"); - RuleFor(t => t.LocalLanguage).AlphaNumericOnly(); } } } |