From e2164d088eb5209e2baecf09fbee06d6326fe910 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sat, 14 Oct 2023 15:56:41 -0400 Subject: track core updates & pki multi key --- .../src/Endpoints/KeepAliveEndpoint.cs | 18 +- .../src/Endpoints/LoginEndpoint.cs | 90 +++----- .../src/Endpoints/LogoutEndpoint.cs | 9 +- .../src/Endpoints/PasswordResetEndpoint.cs | 28 +-- .../src/Endpoints/PkiLoginEndpoint.cs | 253 +++++++++------------ .../src/Endpoints/ProfileEndpoint.cs | 30 +-- .../src/MFA/PkiAuthPublicKey.cs | 72 ++++++ .../src/MFA/UserMFAExtensions.cs | 163 +++++++++---- 8 files changed, 356 insertions(+), 307 deletions(-) create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs (limited to 'plugins/VNLib.Plugins.Essentials.Accounts') diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs index e540405..ac0c8eb 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/KeepAliveEndpoint.cs @@ -23,11 +23,9 @@ */ using System; -using System.Net; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Endpoints; -using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Extensions.Loading; @@ -51,12 +49,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints InitPathAndLog(path, pbase.Log); } - protected override VfReturnType Get(HttpEntity entity) - { - //Return okay - entity.CloseResponse(HttpStatusCode.OK); - return VfReturnType.VirtualSkip; - } + protected override VfReturnType Get(HttpEntity entity) => VirtualOk(entity); //Allow post to update user's credentials protected override VfReturnType Post(HttpEntity entity) @@ -72,16 +65,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //reauthorize the client entity.ReAuthorizeClient(webm); - webm.Success = true; - + webm.Success = true; //Send the update message to the client - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Return okay - entity.CloseResponse(HttpStatusCode.OK); - return VfReturnType.VirtualSkip; + return VirtualOk(entity); } } } \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs index 0d10811..b01cc3d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs @@ -75,20 +75,20 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private readonly IPasswordHashingProvider Passwords; private readonly MFAConfig MultiFactor; private readonly IUserManager Users; - private readonly uint MaxFailedLogins; - private readonly TimeSpan FailedCountTimeout; + private readonly FailedLoginLockout _lockout; public LoginEndpoint(PluginBase pbase, IConfigScope config) { - string? path = config["path"].GetString(); - FailedCountTimeout = config["failed_count_timeout_sec"].GetTimeSpan(TimeParseType.Seconds); - MaxFailedLogins = config["failed_count_max"].GetUInt32(); + string path = config.GetRequiredProperty("path", p => p.GetString()!); + TimeSpan duration = config["failed_count_timeout_sec"].GetTimeSpan(TimeParseType.Seconds); + uint maxLogins = config["failed_count_max"].GetUInt32(); InitPathAndLog(path, pbase.Log); Passwords = pbase.GetOrCreateSingleton(); Users = pbase.GetOrCreateSingleton(); MultiFactor = pbase.GetConfigElement(); + _lockout = new(maxLogins, duration); } protected override ERRNO PreProccess(HttpEntity entity) @@ -102,8 +102,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Conflict if user is logged in if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any)) { - entity.CloseResponse(HttpStatusCode.Conflict); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.Conflict); } //If mfa is enabled, allow processing via mfa @@ -129,15 +128,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(loginMessage != null, "Invalid request data")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //validate the message if (!LmValidator.Validate(loginMessage, webm)) { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } //Time to get the user @@ -146,12 +143,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Make sure account exists if (webm.Assert(user != null, INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } - + + bool locked = _lockout.CheckOrClear(user, entity.RequestedTimeUtc); + //Make sure the account has not been locked out - if (webm.Assert(!UserLoginLocked(user, entity.RequestedTimeUtc), LOCKED_ACCOUNT_MESSAGE)) + if (webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE)) { goto Cleanup; } @@ -167,13 +165,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Inc failed login count - user.FailedLoginIncrement(entity.RequestedTimeUtc); + _lockout.Increment(user, entity.RequestedTimeUtc); webm.Result = INVALID_MESSAGE; Cleanup: await user.ReleaseAsync(); - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity ,webm); } catch (UserUpdateException uue) { @@ -185,7 +182,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) { //Verify password before we tell the user the status of their account for security reasons - if (!Passwords.Verify(user.PassHash, loginMessage.Password)) + if (!Passwords.Verify(user.PassHash!, loginMessage.Password)) { return false; } @@ -274,8 +271,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(request != null, "Invalid request data")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //Recover upgrade jwt @@ -283,8 +279,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(upgradeJwt != null, "Missing required upgrade data")) { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } //Recover stored signature @@ -292,8 +287,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Recover upgrade data from upgrade message @@ -301,8 +295,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //recover user account @@ -310,30 +303,28 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(user != null, MFA_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } - bool locked = UserLoginLocked(user, entity.RequestedTimeUtc); + bool locked = _lockout.CheckOrClear(user, entity.RequestedTimeUtc); //Make sure the account has not been locked out - if (!webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE)) + if (webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE)) { - //process mfa login - LoginMfa(entity, user, request, upgrade, webm); + //Locked, so clear stored signature + entity.Session.MfaUpgradeSecret(null); } else { - //Locked, so clear stored signature - entity.Session.MfaUpgradeSecret(null); + //process mfa login + LoginMfa(entity, user, request, upgrade, webm); } //Update user on clean process await user.ReleaseAsync(); //Close rseponse - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm) @@ -355,7 +346,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints webm.Result = "Please check your code."; //Increment flc and update the user in the store - user.FailedLoginIncrement(entity.RequestedTimeUtc); + _lockout.Increment(user, entity.RequestedTimeUtc); return; } //Valid, complete @@ -386,29 +377,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); } - public bool UserLoginLocked(IUser user, DateTimeOffset now) - { - //Recover last counter value - TimestampedCounter flc = user.FailedLoginCount(); - - if (flc.Count < MaxFailedLogins) - { - //Period exceeded - return false; - } - - //See if the flc timeout period has expired - if (flc.LastModified.Add(FailedCountTimeout) < now) - { - //clear flc flag - user.ClearFailedLoginCount(); - return false; - } - - //Count has been exceeded, and has not timed out yet - return true; - } - private sealed class MfaUpgradeWebm : ValErrWebMessage { diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs index e5adb17..09b5532 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LogoutEndpoint.cs @@ -22,9 +22,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -using System; -using System.Net; - using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Essentials.Endpoints; @@ -56,16 +53,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) { entity.InvalidateLogin(); - entity.CloseResponse(HttpStatusCode.OK); - return VfReturnType.VirtualSkip; } else { //Detatch the session to cause client only invalidation entity.Session.Detach(); - entity.CloseResponse(HttpStatusCode.OK); - return VfReturnType.VirtualSkip; } + + return VirtualOk(entity); } } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs index 10eff17..6f8cb77 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs @@ -102,20 +102,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints protected override async ValueTask PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); + //get the request body using PasswordResetMesage? pwReset = await entity.GetJsonFromFileAsync(); if (webm.Assert(pwReset != null, "No request specified")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //Validate if(!ResetMessValidator.Validate(pwReset, webm)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //get the user's entry in the table @@ -123,23 +122,20 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if(webm.Assert(user != null, "An error has occured, please log-out and try again")) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //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; + return VirtualOk(entity, webm); } //Verify the user's old password if (!Passwords.Verify(user.PassHash, pwReset.Current.AsSpan())) { webm.Result = "Please check your current password"; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Check if totp is enabled @@ -150,8 +146,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //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; + return VirtualOk(entity, webm); } //Veriy totp code @@ -159,8 +154,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(verified, "Please check your TOTP code and try again")) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } } //continue @@ -174,8 +168,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { //error webm.Result = "Your password could not be updated"; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Publish to user database @@ -184,8 +177,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //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; + return VirtualOk(entity, webm); } private sealed class PasswordResetMesage : PrivateStringManager diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs index 42c4ba6..6d9e049 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs @@ -63,17 +63,11 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private static JwtLoginValidator LwValidator { get; } = new(); private static IValidator AuthValidator { get; } = AuthenticationInfo.GetValidator(); - private static IValidator UserJwkValidator { get; } = GetKeyValidator(); + private static IValidator 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(); + private readonly FailedLoginLockout _lockout; public PkiLoginEndpoint(PluginBase plugin, IConfigScope config) @@ -84,6 +78,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Load config _config = config.DeserialzeAndValidate(); _users = plugin.GetOrCreateSingleton(); + _lockout = new((uint)_config.MaxFailedLogins, TimeSpan.FromSeconds(_config.FailedCountTimeoutSec)); Log.Verbose("PKI endpoint enabled"); } @@ -98,8 +93,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Conflict if user is logged in if (entity.IsClientAuthorized(AuthorzationCheckLevel.Any)) { - entity.CloseResponse(HttpStatusCode.Conflict); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.Conflict); } ValErrWebMessage webm = new(); @@ -109,15 +103,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if(webm.Assert(login != null, INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Validate login message if(!LwValidator.Validate(login, webm)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } IUser? user = null; @@ -130,55 +122,30 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints catch (KeyNotFoundException) { webm.Result = INVALID_MESSAGE; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } catch (FormatException) { webm.Result = INVALID_MESSAGE; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } try { - AuthenticationInfo authInfo; + AuthenticationInfo authInfo = default; + + //Get auth info from jwt + bool isValidAuth = GetAuthInfo(jwt, entity.RequestedTimeUtc, ref authInfo); - //Get the signed payload message - using (JsonDocument payload = jwt.GetPayload()) + if(webm.Assert(isValidAuth, INVALID_MESSAGE)) { - 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"), - }; + return VirtualOk(entity, webm); } //Validate auth info if (!AuthValidator.Validate(authInfo, webm)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Get the user from the email address @@ -186,40 +153,35 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if (webm.Assert(user != null, INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Check failed login count - if(webm.Assert(UserLoginLocked(user, entity.RequestedTimeUtc) == false, INVALID_MESSAGE)) + if(webm.Assert(_lockout.CheckOrClear(user, entity.RequestedTimeUtc) == false, INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Now we can verify the signed message against the stored key - if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId) == true, INVALID_MESSAGE)) + if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE)) { //increment flc on invalid signature - user.FailedLoginIncrement(entity.RequestedTimeUtc); + _lockout.Increment(user, entity.RequestedTimeUtc); await user.ReleaseAsync(); - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Account status must be active if(webm.Assert(user.Status == UserStatus.Active, INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Must be local account if (webm.Assert(user.IsLocalAccount(), INVALID_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //User is has been authenticated @@ -239,11 +201,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints }; //Close response, user is now logged-in - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } catch { + /* + * If an internal error occurs after the authorization has been + * generated, we need to clear the login state that has been created. + */ entity.InvalidateLogin(); throw; } @@ -254,9 +219,31 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } } - /* - * This endpoint also enables - */ + protected override async ValueTask GetAsync(HttpEntity entity) + { + //This endpoint requires valid authorization + if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) + { + return VirtualClose(entity, HttpStatusCode.Unauthorized); + } + + ValErrWebMessage webm = new(); + + //Get current user + using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID); + + if (webm.Assert(user != null, "User account is invalid")) + { + return VirtualOk(entity); + } + + //Get the uesr's stored keys + webm.Result = user.PkiGetAllPublicKeys(); + webm.Success = true; + + return VirtualOk(entity, webm); + } + protected override async ValueTask PatchAsync(HttpEntity entity) { //Check for config flag @@ -267,29 +254,23 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //This endpoint requires valid authorization if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) { - entity.CloseResponse(HttpStatusCode.Unauthorized); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.Unauthorized); } ValErrWebMessage webm = new(); //Get the request body - using JsonDocument? request = await entity.GetJsonFromFileAsync(); + PkiAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync(); - if(webm.Assert(request != null, "The request message is not valid")) - { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + if(webm.Assert(pubKey != null, "The request message is not valid")) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } - //Get the jwk from the request body - using ReadOnlyJsonWebKey jwk = new(request.RootElement); - //Validate the user's jwk - if(!UserJwkValidator.Validate(jwk, webm)) + if(!UserJwkValidator.Validate(pubKey, webm)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Get the user account @@ -298,50 +279,42 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //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; + return VirtualOk(entity, webm); } //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; + return VirtualOk(entity, webm); } try { //Try to get the ECDA instance to confirm the key data could be recovered properly - using ECDsa? testAlg = jwk.GetECDsaPublicKey(); + using ECDsa? testAlg = pubKey.GetECDsaPublicKey(); if (webm.Assert(testAlg != null, "Your JWK is not valid")) { webm.Result = "Your JWK is not valid"; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } } 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 keyParams = ExtractKeyData(jwk); + return VirtualOk(entity, webm); + } - //Update user's key params - user.PKISetUserKey(keyParams); + //Update user's key, or add it if it doesn't exist + user.PKIAddPublicKey(pubKey); //publish changes await user.ReleaseAsync(); webm.Result = "Successfully updated your PKI authentication method"; webm.Success = true; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } protected override async ValueTask DeleteAsync(HttpEntity entity) @@ -355,8 +328,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //This endpoint requires valid authorization if (!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical)) { - entity.CloseResponse(HttpStatusCode.Unauthorized); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.Unauthorized); } ValErrWebMessage webm = new(); @@ -367,63 +339,63 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //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; + return VirtualOk(entity, webm); } //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; + return VirtualOk(entity, webm); } - //Remove the key - user.PKISetUserKey(null); + //try to get a single key id to delete + if(entity.QueryArgs.TryGetValue("id", out string? keyId)) + { + //Remove only the specified key + user.PKIRemovePublicKey(keyId); + webm.Result = "You have successfully removed the key from your account"; + } + else + { + //Delete all keys + user.PKISetPublicKeys(null); + webm.Result = "You have successfully disabled PKI login"; + } + await user.ReleaseAsync(); - webm.Result = "You have successfully disabled PKI login"; webm.Success = true; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } - public bool UserLoginLocked(IUser user, DateTimeOffset now) + private bool GetAuthInfo(JsonWebToken jwt, DateTimeOffset now, ref AuthenticationInfo authInfo) { - //Recover last counter value - TimestampedCounter flc = user.FailedLoginCount(); + //Get the signed payload message + using JsonDocument payload = jwt.GetPayload(); + + long unixSec = payload.RootElement.GetProperty("iat").GetInt64(); + + DateTimeOffset clientIat = DateTimeOffset.FromUnixTimeSeconds(unixSec); - if (flc.Count < _config.MaxFailedLogins) + if (clientIat.Add(_config.MaxJwtTimeDifference) < now) { - //Period exceeded return false; } - //See if the flc timeout period has expired - if (flc.LastModified.AddSeconds(_config.FailedCountTimeoutSec) < now) + if (clientIat.Subtract(_config.MaxJwtTimeDifference) > now) { - //clear flc flag - user.ClearFailedLoginCount(); return false; } - //Count has been exceeded, and has not timed out yet - return true; - } - - private static IReadOnlyDictionary ExtractKeyData(ReadOnlyJsonWebKey key) - { - Dictionary 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")!; + //Recover the authenticator information + authInfo = new() + { + EmailAddress = payload.RootElement.GetPropString("sub"), + KeyId = payload.RootElement.GetPropString("keyid"), + SerialNumber = payload.RootElement.GetPropString("serial"), + }; - return keyData; + return true; } private sealed class JwtLoginValidator : ClientSecurityMessageValidator @@ -529,21 +501,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } } - private static IValidator GetKeyValidator() + private static IValidator GetKeyValidator() { - InlineValidator val = new(); + InlineValidator 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")) + val.RuleFor(a => a.Curve) .NotEmpty() .WithName("crv") .Must(p => AllowedCurves.Contains(p, StringComparer.Ordinal)) @@ -556,11 +523,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints val.RuleFor(a => a.Algorithm) .NotEmpty() + .WithName("alg") .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")) + val.RuleFor(a => a.X) .NotEmpty() .WithName("x") .Length(10, 200) @@ -568,9 +536,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints .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")) + val.RuleFor(a => a.Y) .NotEmpty() .WithName("y") .Length(10, 200) diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs index 7dfb8a7..22cde19 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/ProfileEndpoint.cs @@ -59,13 +59,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { //get user data from database using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); + //Make sure the account exists if (user == null || user.Status != UserStatus.Active) { //Account was not found - entity.CloseResponse(HttpStatusCode.NotFound); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.NotFound); } + //Get the stored profile AccountData? profile = user.GetProfile(); //No profile found, so return an empty "profile" @@ -76,9 +77,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //created time in rfc1123 gmt time Created = user.Created.ToString("R") }; + //Serialize the profile and return to user - entity.CloseResponseJson(HttpStatusCode.OK, profile); - return VfReturnType.VirtualSkip; + return VirtualOkJson(entity, profile); } protected override async ValueTask PostAsync(HttpEntity entity) { @@ -89,41 +90,42 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints AccountData? updateMessage = await entity.GetJsonFromFileAsync(SR_OPTIONS); if (webm.Assert(updateMessage != null, "Malformatted payload")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, HttpStatusCode.BadRequest); } + //Validate the new account data if (!AccountValidations.AccountDataValidator.Validate(updateMessage, webm)) { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } + //Get the user from database using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); //Make sure the user exists if (webm.Assert(user != null, "Account does not exist")) { //Should probably log the user out here - entity.CloseResponseJson(HttpStatusCode.NotFound, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.NotFound); } + //Overwite the current profile data (will also sanitize inputs) user.SetProfile(updateMessage); //Update the user only if successful await user.ReleaseAsync(); + webm.Result = "Successfully updated account"; webm.Success = true; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + + return VirtualOk(entity, webm); } //Catch an account update exception catch (UserUpdateException uue) { Log.Error(uue, "An error occured while the user account is being updated"); + //Return message to client webm.Result = "An error occured while updating your account, try again later"; - entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.InternalServerError); } } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs new file mode 100644 index 0000000..a941852 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs @@ -0,0 +1,72 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: PkiAuthPublicKey.cs +* +* PkiAuthPublicKey.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.Text.Json.Serialization; + +using VNLib.Hashing.IdentityUtility; + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + /// + /// A json serializable JWK format public key for PKI authentication + /// + public record class PkiAuthPublicKey : IJsonWebKey + { + [JsonPropertyName("kid")] + public string? KeyId { get; set; } + + [JsonPropertyName("kty")] + public string? KeyType { get; set; } + + [JsonPropertyName("crv")] + public string? Curve { get; set; } + + [JsonPropertyName("x")] + public string? X { get; set; } + + [JsonPropertyName("y")] + public string? Y { get; set; } + + [JsonPropertyName("alg")] + public string Algorithm { get; set; } = string.Empty; + + [JsonIgnore] + public JwkKeyUsage KeyUse => JwkKeyUsage.Signature; + + /// + public string? GetKeyProperty(string propertyName) + { + return propertyName switch + { + "kid" => KeyId, + "kty" => KeyType, + "crv" => Curve, + "x" => X, + "y" => Y, + "alg" => Algorithm, + _ => null, + }; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs index bd434ae..f1d14f4 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -39,12 +39,11 @@ using VNLib.Plugins.Essentials.Sessions; namespace VNLib.Plugins.Essentials.Accounts.MFA { - internal static class UserMFAExtensions + public static class UserMFAExtensions { public const string WEBAUTHN_KEY_ENTRY = "mfa.fido"; public const string TOTP_KEY_ENTRY = "mfa.totp"; public const string SESSION_SIG_KEY = "mfa.sig"; - public const string USER_PKI_ENTRY = "mfa.pki"; /// @@ -97,7 +96,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// The to modify the TOTP configuration of /// The raw secret that was encrypted and stored in the , to send to the client /// - public static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config) + internal static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config) { _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP"); //Generate a random key @@ -118,7 +117,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise /// /// - public static bool VerifyTOTP(this MFAConfig config, IUser user, uint code) + internal static bool VerifyTOTP(this MFAConfig config, IUser user, uint code) { //Get the base32 TOTP secret for the user and make sure its actually set string base32Secret = user.MFAGetTOTPSecret(); @@ -126,13 +125,33 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA { return false; } - //Alloc buffer with zero o - using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAlloc(base32Secret.Length, true); - ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer); - //Verify the TOTP using the decrypted secret - bool isValid = count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig); - //Zero out the buffer - MemoryUtil.InitializeBlock(buffer.Span); + + int length = base32Secret.Length; + bool isValid; + + if (length > 256) + { + //Alloc buffer with zero o + using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true); + + ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer); + //Verify the TOTP using the decrypted secret + isValid = count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig); + //Zero out the buffer + MemoryUtil.InitializeBlock(buffer.Span); + } + else + { + //stack alloc buffer + Span buffer = stackalloc byte[base32Secret.Length]; + + ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer); + //Verify the TOTP using the decrypted secret + isValid = count && VerifyTOTP(code, buffer[..(int)count], config.TOTPConfig); + //Zero out the buffer + MemoryUtil.InitializeBlock(buffer); + } + return isValid; } @@ -202,8 +221,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA #endregion #region PKI - const int JWK_KEY_BUFFER_SIZE = 2048; - + /// /// Gets a value that determines if the user has PKI enabled /// @@ -220,43 +238,46 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified 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; - } + /* + * Since multiple keys can be stored, we need to recover the key that matches the desired key id + */ + PkiAuthPublicKey? pub = PkiGetAllPublicKeys(user)?.FirstOrDefault(p => keyId.Equals(p.KeyId, StringComparison.Ordinal)); - //Confim the key id matches - if(!keyId.Equals(jwk.KeyId, StringComparison.OrdinalIgnoreCase)) + if(pub == null) { return false; } - + //verify the jwt - return jwt.VerifyFromJwk(jwk); + return jwt.VerifyFromJwk(pub); } - - public static void PKISetUserKey(this IUser user, IReadOnlyDictionary? keyFields) + + /// + /// Stores an array of public keys in the user's account object + /// + /// + /// The array of jwk format keys to store for the user + public static void PKISetPublicKeys(this IUser user, PkiAuthPublicKey[]? authKeys) { - if(keyFields == null) + if(authKeys == null || authKeys.Length == 0) { user[USER_PKI_ENTRY] = null!; return; } //Serialize the key data - byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(keyFields, Statics.SR_OPTIONS); - - //convert to base32 string before writing user data - string base64 = Convert.ToBase64String(keyData); + byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(authKeys, Statics.SR_OPTIONS); - //Store key data - user[USER_PKI_ENTRY] = base64; + //convert to base64 string before writing user data + user[USER_PKI_ENTRY] = VnEncoding.ToBase64UrlSafeString(keyData, false); } - private static ReadOnlyJsonWebKey? RecoverKey(IUser user) + /// + /// Gets all public keys stored in the user's account object + /// + /// + /// The array of public keys if the exist + public static PkiAuthPublicKey[]? PkiGetAllPublicKeys(this IUser user) { string? keyData = user[USER_PKI_ENTRY]; @@ -264,25 +285,67 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA { return null; } + + //Alloc bin buffer for base64 conversion + using UnsafeMemoryHandle binBuffer = MemoryUtil.UnsafeAllocNearestPage(keyData.Length, true); - //Get buffer to recover the key data from - byte[] buffer = ArrayPool.Shared.Rent(JWK_KEY_BUFFER_SIZE); - try + //Recover base64 bytes from key data + ERRNO bytes = VnEncoding.Base64UrlDecode(keyData, binBuffer.Span); + if (!bytes) { - //Recover base64 bytes from key data - ERRNO bytes = VnEncoding.TryFromBase64Chars(keyData, buffer); - if (!bytes) - { - return null; - } - //Recover json from the decoded binary data - return new ReadOnlyJsonWebKey(buffer.AsSpan(0, bytes)); + return null; } - finally + + //Deserialize the the key array + return JsonSerializer.Deserialize(binBuffer.AsSpan(0, bytes), Statics.SR_OPTIONS); + } + + /// + /// Removes a single pki key by it's id + /// + /// + /// The id of the key to remove + public static void PKIRemovePublicKey(this IUser user, string keyId) + { + //get all keys + PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user); + if(keys == null) + { + return; + } + + //remove the key + keys = keys.Where(k => !keyId.Equals(k.KeyId, StringComparison.Ordinal)).ToArray(); + + //store the new key array + PKISetPublicKeys(user, keys); + } + + /// + /// Adds a single pki key to the user's account object, or overwrites + /// and existing key with the same id + /// + /// + /// The key to add to the list of user-keys + public static void PKIAddPublicKey(this IUser user, PkiAuthPublicKey key) + { + //get all keys + PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user); + + if (keys == null) + { + keys = new PkiAuthPublicKey[] { key }; + } + else { - MemoryUtil.InitializeBlock(buffer.AsSpan()); - ArrayPool.Shared.Return(buffer); + //remove the key if it already exists, then append the new key + keys = keys.Where(k => !key.KeyId.Equals(k.KeyId, StringComparison.Ordinal)) + .Append(key) + .ToArray(); } + + //store the new key array + PKISetPublicKeys(user, keys); } #endregion @@ -416,9 +479,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret)); } - public static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!; + internal static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!; - public static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY]; + internal static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY]; } readonly record struct MfaUpgradeMessage(string ClientJwt, string SessionKey); -- cgit