aboutsummaryrefslogtreecommitdiff
path: root/plugins/VNLib.Plugins.Essentials.Accounts/src
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-03-19 13:56:27 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-03-19 13:56:27 -0400
commit78901f761e5b8358d02d1841bee4c60d97c94760 (patch)
treed7f6b4d268f74c422ab642249b9a92d72598c986 /plugins/VNLib.Plugins.Essentials.Accounts/src
parent9a73c170946020e6568de45e69a589d9896d565c (diff)
RestSharp version update, PKI optional login endpoint
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs5
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs21
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs101
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs577
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs73
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs123
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs27
7 files changed, 782 insertions, 145 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
index 9c91b69..e2304c0 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
@@ -86,6 +86,11 @@ namespace VNLib.Plugins.Essentials.Accounts
this.Route<MFAEndpoint>();
}
+ if (this.HasConfigForType<PkiLoginEndpoint>())
+ {
+ this.Route<PkiLoginEndpoint>();
+ }
+
//Only export the account security service if the configuration element is defined
if (this.HasConfigForType<AccountSecProvider>())
{
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
index e78d2da..ea6bab1 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
@@ -29,6 +29,8 @@ using System.Threading.Tasks;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
+using FluentValidation;
+
using VNLib.Utils.Memory;
using VNLib.Utils.Logging;
using VNLib.Utils.Extensions;
@@ -42,7 +44,6 @@ using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
-
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
@@ -142,7 +143,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Make sure the account has not been locked out
- if (webm.Assert(!UserLoginLocked(user), LOCKED_ACCOUNT_MESSAGE))
+ if (webm.Assert(!UserLoginLocked(user, entity.RequestedTimeUtc), LOCKED_ACCOUNT_MESSAGE))
{
goto Cleanup;
}
@@ -302,7 +303,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VfReturnType.VirtualSkip;
}
- bool locked = UserLoginLocked(user);
+ bool locked = UserLoginLocked(user, entity.RequestedTimeUtc);
//Make sure the account has not been locked out
if (!webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE))
@@ -383,27 +384,27 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
webm.Success = true;
//Write to log
Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
- }
+ }
- public bool UserLoginLocked(IUser user)
+ public bool UserLoginLocked(IUser user, DateTimeOffset now)
{
//Recover last counter value
TimestampedCounter flc = user.FailedLoginCount();
-
- if(flc.Count < MaxFailedLogins)
+
+ if (flc.Count < MaxFailedLogins)
{
//Period exceeded
return false;
}
-
+
//See if the flc timeout period has expired
- if (flc.LastModified.Add(FailedCountTimeout) < DateTimeOffset.UtcNow)
+ if (flc.LastModified.Add(FailedCountTimeout) < now)
{
//clear flc flag
user.FailedLoginCount(0);
return false;
}
-
+
//Count has been exceeded, and has not timed out yet
return true;
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
index 0b015a4..998cee4 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
@@ -78,21 +78,27 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity)
{
- List<string> enabledModes = new(2);
+ string[] enabledModes = new string[3];
//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()))
+ if (user?.MFATotpEnabled() == true)
{
- enabledModes.Add("totp");
+ enabledModes[0] = "totp";
}
//TODO Set fido flag if enabled
if (!string.IsNullOrWhiteSpace(""))
{
- enabledModes.Add("fido");
+ enabledModes[1] = "fido";
+ }
+
+ //PKI enabled
+ if (user?.PKIEnabled() == true)
+ {
+ enabledModes[2] = "pki";
}
//Return mfa modes as an array
@@ -176,45 +182,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
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
- UnsafeMemoryHandle<byte> outputBuffer = MemoryUtil.UnsafeAlloc<byte>(4096, true);
-
- try
- {
- //Encrypt the secret for the client
- ERRNO count = entity.TryEncryptClientData(secretBuffer, outputBuffer.Span);
-
- if (!count)
- {
- webm.Result = "There was an error updating your credentials";
- //If this code is running, the client should have a valid public key stored, but log it anyway
- Log.Warn("TOTP secret encryption failed, for requested user {uid}", entity.Session.UserID);
- break;
- }
-
- webm.Result = new TOTPUpdateMessage()
- {
- Issuer = MultiFactor.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])
- };
-
- //set success flag
- webm.Success = true;
- }
- finally
- {
- //dispose the output buffer
- outputBuffer.Dispose();
- MemoryUtil.InitializeBlock(secretBuffer.AsSpan());
- }
- //Only write changes to the db of operation was successful
- await user.ReleaseAsync();
+ //Update TOTP secret for user
+ await UpdateUserTotp(entity, user, webm);
}
break;
default:
@@ -313,5 +282,51 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VfReturnType.BadRequest;
}
}
+
+ private async Task UpdateUserTotp(HttpEntity entity, IUser user, WebMessage webm)
+ {
+ //generate a new secret (passing the buffer which will get copied to an array because the pw bytes can be modified during encryption)
+ byte[] secretBuffer = user.MFAGenreateTOTPSecret(MultiFactor);
+ //Alloc output buffer
+ UnsafeMemoryHandle<byte> outputBuffer = MemoryUtil.UnsafeAlloc<byte>(4096, true);
+
+ try
+ {
+ //Encrypt the secret for the client
+ ERRNO count = entity.TryEncryptClientData(secretBuffer, outputBuffer.Span);
+
+ if (!count)
+ {
+ webm.Result = "There was an error updating your credentials";
+
+ //If this code is running, the client should have a valid public key stored, but log it anyway
+ Log.Warn("TOTP secret encryption failed, for requested user {uid}", entity.Session.UserID);
+ }
+ else
+ {
+ webm.Result = new TOTPUpdateMessage()
+ {
+ 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])
+ };
+
+ //set success flag
+ webm.Success = true;
+
+ //Only write changes to the db of operation was successful
+ await user.ReleaseAsync();
+ }
+ }
+ finally
+ {
+ //dispose the output buffer
+ outputBuffer.Dispose();
+ MemoryUtil.InitializeBlock(secretBuffer.AsSpan());
+ }
+ }
}
} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
new file mode 100644
index 0000000..06ccd60
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
@@ -0,0 +1,577 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: PkiLoginEndpoint.cs
+*
+* PkiLoginEndpoint.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/.
+*/
+
+using System;
+using System.Net;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Security.Cryptography;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Utils;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Essentials.Accounts.MFA;
+using VNLib.Plugins.Essentials.Accounts.Validators;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.Endpoints
+{
+ [ConfigurationName("pki_auth_endpoint")]
+ internal sealed class PkiLoginEndpoint : UnprotectedWebEndpoint
+ {
+ public const string INVALID_MESSAGE = "Your assertion is invalid, please regenerate and try again";
+
+ private static readonly ImmutableArray<string> AllowedCurves = new string[3] { "P-256", "P-384", "P-521"}.ToImmutableArray();
+ private static readonly ImmutableArray<string> AllowedAlgs = new string[3] { "ES256", "ES384", "ES512" }.ToImmutableArray();
+
+ private static JwtLoginValidator LwValidator { get; } = new();
+ private static IValidator<AuthenticationInfo> AuthValidator { get; } = AuthenticationInfo.GetValidator();
+ private static IValidator<ReadOnlyJsonWebKey> UserJwkValidator { get; } = GetKeyValidator();
+
+ private readonly JwtEndpointConfig _config;
+ private readonly IUserManager _users;
+
+
+ /*
+ * Default protections sessions should be fine (most strict)
+ * No cross-site/cross origin/bad referrer etc
+ */
+ //protected override ProtectionSettings EndpointProtectionSettings { get; } = new();
+
+
+ public PkiLoginEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ string? path = config["path"].GetString();
+ InitPathAndLog(path, plugin.Log);
+
+ //Load config
+ _config = config.DeserialzeAndValidate<JwtEndpointConfig>();
+ _users = plugin.GetOrCreateSingleton<UserManager>();
+
+ Log.Verbose("PKI endpoint enabled");
+ }
+
+ protected override ERRNO PreProccess(HttpEntity entity)
+ {
+ return base.PreProccess(entity) && !entity.Session.IsNew;
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ //Conflict if user is logged in
+ if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any))
+ {
+ entity.CloseResponse(HttpStatusCode.Conflict);
+ return VfReturnType.VirtualSkip;
+ }
+
+ ValErrWebMessage webm = new();
+
+ //Get the login message from the client
+ JwtLoginMessage? login = await entity.GetJsonFromFileAsync<JwtLoginMessage>();
+
+ if(webm.Assert(login != null, INVALID_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Validate login message
+ if(!LwValidator.Validate(login, webm))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ IUser? user = null;
+ JsonWebToken jwt;
+ try
+ {
+ //We can try to recover the jwt data
+ jwt = JsonWebToken.Parse(login.LoginJwt);
+ }
+ catch (KeyNotFoundException)
+ {
+ webm.Result = INVALID_MESSAGE;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ catch (FormatException)
+ {
+ webm.Result = INVALID_MESSAGE;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ try
+ {
+ AuthenticationInfo authInfo;
+
+ //Get the signed payload message
+ using (JsonDocument payload = jwt.GetPayload())
+ {
+ long unixSec = payload.RootElement.GetProperty("iat").GetInt64();
+
+ DateTimeOffset clientIat = DateTimeOffset.FromUnixTimeSeconds(unixSec);
+
+ if (clientIat.Add(_config.MaxJwtTimeDifference) < entity.RequestedTimeUtc)
+ {
+ webm.Result = INVALID_MESSAGE;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ if (clientIat.Subtract(_config.MaxJwtTimeDifference) > entity.RequestedTimeUtc)
+ {
+ webm.Result = INVALID_MESSAGE;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Recover the authenticator information
+ authInfo = new()
+ {
+ EmailAddress = payload.RootElement.GetPropString("sub"),
+ KeyId = payload.RootElement.GetPropString("keyid"),
+ SerialNumber = payload.RootElement.GetPropString("serial"),
+ };
+ }
+
+ //Validate auth info
+ if (!AuthValidator.Validate(authInfo, webm))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the user from the email address
+ user = await _users.GetUserFromEmailAsync(authInfo.EmailAddress!, entity.EventCancellation);
+
+ if (webm.Assert(user != null, INVALID_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Check failed login count
+ if(webm.Assert(UserLoginLocked(user, entity.RequestedTimeUtc) == false, INVALID_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Now we can verify the signed message against the stored key
+ if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId) == true, INVALID_MESSAGE))
+ {
+ //increment flc on invalid signature
+ user.FailedLoginIncrement();
+ await user.ReleaseAsync();
+
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Account status must be active
+ if(webm.Assert(user.Status == UserStatus.Active, INVALID_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Must be local account
+ if (webm.Assert(user.IsLocalAccount(), INVALID_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //User is has been authenticated
+
+ //Authorize the user
+ entity.GenerateAuthorization(login, user, webm);
+
+ //Write user data
+ await user.ReleaseAsync();
+
+ webm.Success = true;
+
+ //Return user data
+ webm.Result = new AccountData()
+ {
+ EmailAddress = user.EmailAddress,
+ };
+
+ //Close response, user is now logged-in
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ catch
+ {
+ entity.InvalidateLogin();
+ throw;
+ }
+ finally
+ {
+ user?.Dispose();
+ jwt.Dispose();
+ }
+ }
+
+ /*
+ * This endpoint also enables
+ */
+ protected override async ValueTask<VfReturnType> PatchAsync(HttpEntity entity)
+ {
+ //Check for config flag
+ if (!_config.EnableKeyUpdate)
+ {
+ return VfReturnType.Forbidden;
+ }
+ //This endpoint requires valid authorization
+ if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical))
+ {
+ entity.CloseResponse(HttpStatusCode.Unauthorized);
+ return VfReturnType.VirtualSkip;
+ }
+
+ ValErrWebMessage webm = new();
+
+ //Get the request body
+ using JsonDocument? request = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(request != null, "The request message is not valid"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the jwk from the request body
+ using ReadOnlyJsonWebKey jwk = new(request.RootElement);
+
+ //Validate the user's jwk
+ if(!UserJwkValidator.Validate(jwk, webm))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the user account
+ using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
+
+ //Confirm not null, this should only happen if user is removed from table while still logged in
+ if(webm.Assert(user != null, "You may not configure PKI authentication"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Local account is required
+ if (webm.Assert(user.IsLocalAccount(), "You do not have a local account, you may not configure PKI authentication"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ try
+ {
+ //Try to get the ECDA instance to confirm the key data could be recovered properly
+ using ECDsa? testAlg = jwk.GetECDsaPublicKey();
+
+ if (webm.Assert(testAlg != null, "Your JWK is not valid"))
+ {
+ webm.Result = "Your JWK is not valid";
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ catch(Exception ex)
+ {
+ Log.Debug(ex);
+ webm.Result = "Your JWK is not valid";
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Extract the user's EC key minimum parameters
+ IReadOnlyDictionary<string, string> keyParams = ExtractKeyData(jwk);
+
+ //Update user's key params
+ user.PKISetUserKey(keyParams);
+
+ //publish changes
+ await user.ReleaseAsync();
+
+ webm.Result = "Successfully updated your PKI authentication method";
+ webm.Success = true;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ protected override async ValueTask<VfReturnType> DeleteAsync(HttpEntity entity)
+ {
+ //Check for config flag
+ if (!_config.EnableKeyUpdate)
+ {
+ return VfReturnType.Forbidden;
+ }
+
+ //This endpoint requires valid authorization
+ if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical))
+ {
+ entity.CloseResponse(HttpStatusCode.Unauthorized);
+ return VfReturnType.VirtualSkip;
+ }
+
+ ValErrWebMessage webm = new();
+
+ //Get the user account
+ using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
+
+ //Confirm not null, this should only happen if user is removed from table while still logged in
+ if (webm.Assert(user != null, "You may not configure PKI authentication"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Local account is required
+ if (webm.Assert(user.IsLocalAccount(), "You do not have a local account, you may not configure PKI authentication"))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Remove the key
+ user.PKISetUserKey(null);
+ await user.ReleaseAsync();
+
+ webm.Result = "You have successfully disabled PKI login";
+ webm.Success = true;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ public bool UserLoginLocked(IUser user, DateTimeOffset now)
+ {
+ //Recover last counter value
+ TimestampedCounter flc = user.FailedLoginCount();
+
+ if (flc.Count < _config.MaxFailedLogins)
+ {
+ //Period exceeded
+ return false;
+ }
+
+ //See if the flc timeout period has expired
+ if (flc.LastModified.AddSeconds(_config.FailedCountTimeoutSec) < now)
+ {
+ //clear flc flag
+ user.FailedLoginCount(0);
+ return false;
+ }
+
+ //Count has been exceeded, and has not timed out yet
+ return true;
+ }
+
+ private static IReadOnlyDictionary<string, string> ExtractKeyData(ReadOnlyJsonWebKey key)
+ {
+ Dictionary<string, string> keyData = new();
+
+ keyData["kty"] = key.KeyType!;
+ keyData["use"] = "sig";
+ keyData["crv"] = key.GetKeyProperty("crv")!;
+ keyData["kid"] = key.KeyId!;
+ keyData["alg"] = key.Algorithm!;
+ keyData["x"] = key.GetKeyProperty("x")!;
+ keyData["y"] = key.GetKeyProperty("y")!;
+
+ return keyData;
+ }
+
+ private sealed class JwtLoginValidator : ClientSecurityMessageValidator<JwtLoginMessage>
+ {
+ public JwtLoginValidator() :base()
+ {
+ //Basic jwt validator
+ RuleFor(l => l.LoginJwt)
+ .NotEmpty()
+ .MinimumLength(50)
+ .IllegalCharacters();
+ }
+ }
+
+ sealed class JwtLoginMessage : IClientSecInfo
+ {
+ [JsonPropertyName("pubkey")]
+ public string? PublicKey { get; set; }
+ [JsonPropertyName("clientid")]
+ public string? ClientId { get; set; }
+ [JsonPropertyName("login")]
+ public string? LoginJwt { get; set; }
+ }
+
+ sealed class JwtEndpointConfig : IOnConfigValidation
+ {
+ [JsonIgnore]
+ public TimeSpan MaxJwtTimeDifference { get; set; } = TimeSpan.FromSeconds(30);
+
+ [JsonPropertyName("jwt_time_dif_sec")]
+ public uint TimeDiffSeconds
+ {
+ get => (uint)MaxJwtTimeDifference.TotalSeconds;
+ set => TimeSpan.FromSeconds(value);
+ }
+
+ [JsonPropertyName("enable_key_update")]
+ public bool EnableKeyUpdate { get; set; } = true;
+
+ [JsonPropertyName("max_login_attempts")]
+ public int MaxFailedLogins { get; set; } = 10;
+
+ [JsonPropertyName("failed_attempt_timeout_sec")]
+ public double FailedCountTimeoutSec { get; set; } = 300;
+
+ public void Validate()
+ {
+ Validator.ValidateAndThrow(this);
+ }
+
+ private static IValidator<JwtEndpointConfig> Validator { get; } = GetValidator();
+ private static IValidator<JwtEndpointConfig> GetValidator()
+ {
+ InlineValidator<JwtEndpointConfig> val = new();
+
+ val.RuleFor(c => c.TimeDiffSeconds)
+ .GreaterThan((uint)1)
+ .WithMessage("You must specify a JWT IAT time difference greater than 0 seconds");
+
+ val.RuleFor(c => c.MaxFailedLogins)
+ .GreaterThan(0);
+
+ val.RuleFor(c => c.FailedCountTimeoutSec)
+ .GreaterThan(0);
+
+
+ return val;
+ }
+
+ }
+
+ readonly record struct AuthenticationInfo
+ {
+ public readonly string? EmailAddress { get; init; }
+
+ public readonly string? KeyId { get; init; }
+
+ public readonly string? SerialNumber { get; init; }
+
+ public static IValidator<AuthenticationInfo> GetValidator()
+ {
+ InlineValidator<AuthenticationInfo> val = new();
+
+ val.RuleFor(l => l.EmailAddress)
+ .NotEmpty()
+ .Length(5, 100)
+ .EmailAddress();
+
+ val.RuleFor(l => l.SerialNumber)
+ .NotEmpty()
+ .Length(5, 50)
+ .AlphaNumericOnly();
+
+ val.RuleFor(l => l.KeyId)
+ .NotEmpty()
+ .Length(2, 50)
+ .AlphaNumericOnly();
+
+ return val;
+ }
+ }
+
+ private static IValidator<ReadOnlyJsonWebKey> GetKeyValidator()
+ {
+ InlineValidator<ReadOnlyJsonWebKey> val = new();
+
+ val.RuleFor(a => a.KeyType)
+ .NotEmpty()
+ .Must(kt => "EC".Equals(kt, StringComparison.Ordinal))
+ .WithMessage("The supplied key is not an EC curve key");
+
+ val.RuleFor(a => a.Use)
+ .NotEmpty()
+ .Must(u => "sig".Equals(u, StringComparison.OrdinalIgnoreCase))
+ .WithMessage("Your key must be configured for signatures");
+
+ val.RuleFor(a => a.GetKeyProperty("crv"))
+ .NotEmpty()
+ .WithName("crv")
+ .Must(p => AllowedCurves.Contains(p, StringComparer.Ordinal))
+ .WithMessage("Your key's curve is not supported");
+
+ val.RuleFor(c => c.KeyId)
+ .NotEmpty()
+ .Length(10, 100)
+ .IllegalCharacters();
+
+ val.RuleFor(a => a.Algorithm)
+ .NotEmpty()
+ .Must(a => AllowedAlgs.Contains(a, StringComparer.Ordinal))
+ .WithMessage("Your key's signature algorithm is not supported");
+
+ //Confirm the x axis parameter is valid
+ val.RuleFor(a => a.GetKeyProperty("x"))
+ .NotEmpty()
+ .WithName("x")
+ .Length(10, 200)
+ .WithMessage("Your key's X EC point public key parameter is not valid")
+ .IllegalCharacters()
+ .WithMessage("Your key's X EC point public key parameter conatins invaid characters");
+
+
+ //Confirm the y axis point is valid
+ val.RuleFor(a => a.GetKeyProperty("y"))
+ .NotEmpty()
+ .WithName("y")
+ .Length(10, 200)
+ .WithMessage("Your key's Y EC point public key parameter is not valid")
+ .IllegalCharacters()
+ .WithMessage("Your key's Y EC point public key parameter conatins invaid characters");
+
+ return val;
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
index 28eb804..079c904 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
@@ -23,7 +23,9 @@
*/
using System;
+using System.Text;
using System.Linq;
+using System.Buffers;
using System.Text.Json;
using System.Collections.Generic;
using System.Security.Cryptography;
@@ -47,6 +49,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
public const string PGP_PUB_KEY = "mfa.pgpp";
public const string SESSION_SIG_KEY = "mfa.sig";
+ public const string USER_PKI_ENTRY = "mfa.pki";
+
/// <summary>
/// Determines if the user account has an
/// </summary>
@@ -188,6 +192,75 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
#endregion
+ #region PKI
+ const int JWK_KEY_BUFFER_SIZE = 2048;
+
+ /// <summary>
+ /// Gets a value that determines if the user has PKI enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has a PKI key stored in their user account</returns>
+ public static bool PKIEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[USER_PKI_ENTRY]);
+
+ /// <summary>
+ /// Verifies a PKI login JWT against the user's stored login key data
+ /// </summary>
+ /// <param name="user">The user requesting a login</param>
+ /// <param name="jwt">The login jwt to verify</param>
+ /// <param name="keyId">The id of the key that generated the request, it must match the id of the stored key</param>
+ /// <returns>True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified</returns>
+ public static bool PKIVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId)
+ {
+ //Recover key data from user, it may not be enabled
+ using ReadOnlyJsonWebKey? jwk = RecoverKey(user);
+
+ if(jwk == null)
+ {
+ return false;
+ }
+
+ //Confim the key id matches
+ if(!keyId.Equals(jwk.KeyId, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ //verify the jwt
+ return jwt.VerifyFromJwk(jwk);
+ }
+
+ public static void PKISetUserKey(this IUser user, IReadOnlyDictionary<string, string>? keyData)
+ {
+ //Store key data
+ user.SetObject(USER_PKI_ENTRY, keyData);
+ }
+
+ private static ReadOnlyJsonWebKey? RecoverKey(IUser user)
+ {
+ string? keyData = user[USER_PKI_ENTRY];
+
+ if(string.IsNullOrEmpty(keyData))
+ {
+ return null;
+ }
+
+ //Get buffer to recover the key data from
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(JWK_KEY_BUFFER_SIZE);
+ try
+ {
+ //Recover bytes and get the jwk from the data
+ int encoded = Encoding.UTF8.GetBytes(keyData, buffer);
+ return new ReadOnlyJsonWebKey(buffer.AsSpan(0, encoded));
+ }
+ finally
+ {
+ MemoryUtil.InitializeBlock(buffer.AsSpan());
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+
+ #endregion
+
#region pgp
private class PgpMfaCred
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
index ded7709..728bc42 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
@@ -32,7 +32,6 @@
*/
using System;
-using System.Text;
using System.Text.Json;
using System.Security.Cryptography;
using System.Diagnostics.CodeAnalysis;
@@ -52,32 +51,26 @@ 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";
+ private const HashAlg ClientTokenHmacType = HashAlg.SHA256;
+
/// <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 static HMAC GetPubKeySigningAlg(byte[] key) => new HMACSHA256(key);
private readonly AccountSecConfig _config;
@@ -255,54 +248,25 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
{
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);
+ using IMemoryHandle<byte> buffer = MemoryUtil.SafeAllocNearestPage<byte>(4000, true);
try
{
using RSA rsa = RSA.Create();
//Import the client's public key
- rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _);
+ rsa.ImportSubjectPublicKeyInfo(PublicKey(publicKey, buffer.Span), out _);
- string pubKey;
+ Span<byte> secretBuffer = buffer.Span[.._config.TokenKeySize];
+ Span<byte> outputBuffer = buffer.Span[_config.TokenKeySize..];
- 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];
- }
+ //Computes a random shared key
+ RandomHash.GetRandomBytes(secretBuffer);
//Encyrpt the private key to send to client
- if (!rsa.TryEncrypt(privKeyBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted))
+ if (!rsa.TryEncrypt(secretBuffer, outputBuffer, ClientEncryptonPadding, out int bytesEncrypted))
{
throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small");
}
@@ -313,7 +277,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
//Client token is the encrypted private key
ClientToken = Convert.ToBase64String(outputBuffer[..bytesEncrypted]),
//Store public key as the server token
- ServerToken = pubKey
+ ServerToken = VnEncoding.ToBase32String(secretBuffer)
};
}
finally
@@ -325,18 +289,6 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
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];
@@ -346,9 +298,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
return false;
}
- //Get the stored public key
- string publicKey = entity.Session.Token;
- if (string.IsNullOrWhiteSpace(publicKey))
+ //Get the stored shared symetric key
+ string sharedKey = entity.Session.Token;
+ if (string.IsNullOrWhiteSpace(sharedKey))
{
return false;
}
@@ -364,15 +316,19 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
{
//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))
+
+ using (UnsafeMemoryHandle<byte> decodeBuffer = MemoryUtil.UnsafeAllocNearestPage<byte>(_config.TokenKeySize, true))
{
- //Import public key
- InitPubKey(publicKey, alg);
+ //Recover the key from base32
+ ERRNO count = VnEncoding.TryFromBase32Chars(sharedKey, decodeBuffer.Span);
- //Verify jwt
- isValid &= jwt.Verify(alg, in DefaultHashAlg);
+ if (!count)
+ {
+ return false;
+ }
+
+ //Verity the jwt against the store symmetric key
+ isValid &= jwt.Verify(decodeBuffer.AsSpan(0, count), ClientTokenHmacType);
}
//Get the message payload
@@ -613,12 +569,8 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
//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);
- }
+ //Sign jwt
+ jwt.Sign(signingKey, ClientTokenHmacType);
//base32 encode the signing key
string base32SigningKey = VnEncoding.ToBase32String(signingKey, false);
@@ -652,6 +604,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
{
pubKey = null;
+ //Check session is valid for use
if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
{
return false;
@@ -678,12 +631,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!;
//verify the client signature
- using (HMAC hmac = GetPubKeySigningAlg(signingKey))
+ if (!jwt.Verify(signingKey, ClientTokenHmacType))
{
- if (!jwt.Verify(hmac))
- {
- return false;
- }
+ return false;
}
//Verify expiration
@@ -756,6 +706,10 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
//Time difference doesnt need to be validated, it may be 0 to effectively disable it
val.RuleFor(c => c.SignedTokenTimeDiff);
+ val.RuleFor(c => c.TokenKeySize)
+ .InclusiveBetween(8, 512)
+ .WithMessage("You should choose an OTP symmetric key size between 8 and 512 bytes");
+
return val;
}
@@ -803,7 +757,12 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
[JsonPropertyName("otp_header_name")]
public string TokenHeaderName { get; set; } = "X-Web-Token";
- public int PasswordChallengeKeySize { get; set; } = 128;
+ /// <summary>
+ /// The size (in bytes) of the symmetric key used
+ /// by the client to sign token messages
+ /// </summary>
+ [JsonPropertyName("otp_key_size")]
+ public int TokenKeySize { get; set; } = 64;
/// <summary>
/// The name of the cookie that stores the user's signed public encryption key
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
index dbb778f..4cdf51b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Validators/LoginMessageValidation.cs
@@ -31,18 +31,10 @@ using VNLib.Plugins.Extensions.Validation;
namespace VNLib.Plugins.Essentials.Accounts.Validators
{
- internal class LoginMessageValidation : AbstractValidator<LoginMessage>
+ internal class LoginMessageValidation : ClientSecurityMessageValidator<LoginMessage>
{
- public LoginMessageValidation()
+ public LoginMessageValidation() :base()
{
- RuleFor(static t => t.ClientId)
- .Length(min: 10, max: 100)
- .WithMessage(errorMessage: "Your browser is not sending required security information");
-
- RuleFor(static t => t.ClientPublicKey)
- .NotEmpty()
- .Length(min: 50, max: 1000)
- .WithMessage(errorMessage: "Your browser is not sending required security information");
/* Rules for user-input on passwords, set max length to avoid DOS */
RuleFor(static t => t.Password)
@@ -67,4 +59,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Validators
.WithMessage(errorMessage: "Please check your system clock");
}
}
+
+ internal class ClientSecurityMessageValidator<T> : AbstractValidator<T> where T: IClientSecInfo
+ {
+ public ClientSecurityMessageValidator()
+ {
+ RuleFor(static t => t.ClientId)
+ .Length(min: 10, max: 100)
+ .WithMessage(errorMessage: "Your browser is not sending required security information");
+
+ RuleFor(static t => t.PublicKey)
+ .NotEmpty()
+ .Length(min: 50, max: 1000)
+ .WithMessage(errorMessage: "Your browser is not sending required security information");
+ }
+ }
}