aboutsummaryrefslogtreecommitdiff
path: root/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints
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/Endpoints
parent9a73c170946020e6568de45e69a589d9896d565c (diff)
RestSharp version update, PKI optional login endpoint
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints')
-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
3 files changed, 646 insertions, 53 deletions
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