aboutsummaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/Endpoints/UsersEndpoint.cs6
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj2
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs28
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs55
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs20
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs149
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs6
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs97
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs144
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs212
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs170
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs848
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Content.Routing/src/PageRouterEntry.cs31
-rw-r--r--plugins/VNLib.Plugins.Essentials.Content.Routing/src/Router.cs28
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs47
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs33
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs59
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialEntryPoint.cs42
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs499
-rw-r--r--plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/LoginMessageValidation.cs15
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();
}
}
}