diff options
Diffstat (limited to 'plugins')
32 files changed, 2091 insertions, 786 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index 219239e..31b4180 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -38,6 +38,9 @@ using VNLib.Plugins.Essentials.Accounts.SecurityProvider; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Essentials.Accounts.MFA.Otp; +using VNLib.Plugins.Essentials.Accounts.MFA.Totp; +using VNLib.Plugins.Essentials.Accounts.MFA.Fido; namespace VNLib.Plugins.Essentials.Accounts { @@ -49,6 +52,7 @@ namespace VNLib.Plugins.Essentials.Accounts private bool SetupMode => HostArgs.HasArgument("--account-setup"); + /// <inheritdoc/> protected override void OnLoad() { //Add optional endpoint routing @@ -84,6 +88,11 @@ namespace VNLib.Plugins.Essentials.Accounts this.Route<PkiLoginEndpoint>(); } + if (this.HasConfigForType<FidoEndpoint>()) + { + this.Route<FidoEndpoint>(); + } + //Only export the account security service if the configuration element is defined if (this.HasConfigForType<AccountSecProvider>()) { @@ -257,7 +266,11 @@ Commands: break; } - user.MFADisable(); + //Disable all mfa methods + user.TotpDisable(); + //user.OtpDisable(); + user.FidoDisable(); + await user.ReleaseAsync(); Log.Information("Successfully disabled MFA for {id}", username); @@ -293,7 +306,7 @@ Commands: } //Update the totp secret and flush changes - user.MFASetTOTPSecret(secret); + user.TotpSetSecret(secret); await user.ReleaseAsync(); Log.Information("Successfully set TOTP secret for {id}", username); @@ -328,7 +341,7 @@ Commands: break; } - PkiAuthPublicKey? pubkey = JsonSerializer.Deserialize<PkiAuthPublicKey>(pubkeyJwk); + OtpAuthPublicKey? pubkey = JsonSerializer.Deserialize<OtpAuthPublicKey>(pubkeyJwk); if (pubkey == null) { Log.Error("You public key is not a JSON object"); @@ -345,7 +358,7 @@ Commands: //Add/update the public key and flush changes - user.PKIAddPublicKey(pubkey); + user.OtpAddPublicKey(pubkey); await user.ReleaseAsync(); Log.Information("Successfully set TOTP secret for {id}", username); diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs new file mode 100644 index 0000000..779d8c9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs @@ -0,0 +1,282 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoEndpoint.cs +* +* FidoEndpoint.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.Net; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +using FluentValidation; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Hashing; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Essentials.Extensions; + +using VNLib.Plugins.Essentials.Accounts.MFA; +using VNLib.Plugins.Essentials.Accounts.MFA.Fido; + + +namespace VNLib.Plugins.Essentials.Accounts.Endpoints +{ + /// <summary> + /// <para> + /// This enpdoint requires Fido to be enabled in the MFA configuration. + /// </para> + /// </summary> + [ConfigurationName("fido_endpoint")] + internal sealed class FidoEndpoint : ProtectedWebEndpoint + { + private static readonly FidoResponseValidator ResponseValidator = new(); + private static readonly FidoClientDataJsonValidtor ClientDataValidator = new(); + + private readonly IUserManager _users; + private readonly FidoConfig _fidoConfig; + private readonly FidoPubkeyAlgorithm[] _supportedAlgs; + + public FidoEndpoint(PluginBase plugin, IConfigScope config) + { + _users = plugin.GetOrCreateSingleton<UserManager>(); + _fidoConfig = plugin.GetConfigElement<MFAConfig>().FIDOConfig + ?? throw new ConfigurationValidationException("Fido configuration was not set, but Fido endpoint was enabled"); + + InitPathAndLog( + path: config.GetRequiredProperty("path", p => p.GetString()!), + log: plugin.Log.CreateScope("Fido-Endpoint") + ); + + /* + * For now hard-code supported algorithms, + * ECDSA is easiest for the time being + */ + + _supportedAlgs = + [ + new FidoPubkeyAlgorithm(algId: -7), //ES256 + new FidoPubkeyAlgorithm(algId: -35), //ES384 + new FidoPubkeyAlgorithm(algId: -36), //ES512 + ]; + } + + protected override VfReturnType Get(HttpEntity entity) + { + return VirtualOk(entity); + } + + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation); + + if (webm.Assert(user != null, "User not found")) + { + return VirtualClose(entity, webm, HttpStatusCode.NotFound); + } + + //TODO: Store challenge in user session + string challenge = RandomHash.GetRandomBase64(16); + + webm.Result = new FidoRegistrationMessage + { + AttestationType = _fidoConfig.AttestationType, + AuthSelection = _fidoConfig.FIDOAuthSelection, + RelyingParty = new FidoRelyingParty + { + Id = entity.Server.RequestUri.DnsSafeHost, + Name = _fidoConfig.SiteName + }, + User = new FidoUserData + { + UserId = user.UserID, + UserName = user.EmailAddress, + DisplayName = user.EmailAddress, + }, + Timeout = _fidoConfig.Timeout, + PubKeyCredParams = _supportedAlgs, + Base64Challenge = challenge, + }; + + webm.Success = true; + + return VirtualOk(entity, webm); + } + + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + using JsonDocument? doc = await entity.GetJsonFromFileAsync(); + + if(webm.Assert(doc != null, "Missing entity message")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if(doc.RootElement.TryGetProperty("response", out JsonElement deviceResponse)) + { + //complete registation of new device + FidoAuthenticatorResponse? res = deviceResponse.Deserialize<FidoAuthenticatorResponse>(); + + if(webm.Assert(res != null, "Mising registation response object")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if(!ResponseValidator.Validate(res, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + return await RegisterDeviceAsync(entity, res); + } + + return VfReturnType.NotFound; + } + + private async ValueTask<VfReturnType> RegisterDeviceAsync( + HttpEntity entity, + FidoAuthenticatorResponse response + ) + { + ValErrWebMessage webm = new(); + + bool isAlgSupported = _supportedAlgs.Any(p => p.AlgId == response.CoseAlgorithmNumber); + + if(webm.Assert(isAlgSupported, "Authenticator does not support the same algorithms as the server")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + FidoClientDataJson? clientData = FidoBase64Util.DeserialzeJson<FidoClientDataJson>(response.Base64ClientData!); + + if(webm.Assert(clientData != null, "Client data json is not valid")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if(!ClientDataValidator.Validate(clientData, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + return VirtualOk(entity); + } + + } + + internal sealed class FidoBase64Util + { + + /// <summary> + /// Takes a base64url encoded JSON string and deserializes it into a + /// given object. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="base64Url">The base64url encoded JSON string to decode</param> + /// <returns>The instance of the object if it could be decoded</returns> + /// <exception cref="JsonException"></exception> + public static T? DeserialzeJson<T>(string base64Url) + { + /* + * We just need to transform the base64 encoded chars back to + * utf8 bytes and then deserialize the object + * + * The length is assumed to be validated before deserialization + */ + + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base64Url.Length); + + ERRNO count = VnEncoding.Base64UrlDecode(base64Url, buffer.Span, System.Text.Encoding.UTF8); + + if (count < 1) + { + throw new JsonException("Failed to decode base64url"); + } + + return JsonSerializer.Deserialize<T>(buffer.AsSpan(0, count)); + } + } + + internal sealed class FidoResponseValidator : AbstractValidator<FidoAuthenticatorResponse> + { + public FidoResponseValidator() + { + RuleFor(c => c.DeviceId) + .NotEmpty() + .WithMessage("Fido 'device_id' must be provided") + .MaximumLength(256); + + RuleFor(c => c.Base64PublicKey) + .NotEmpty() + .WithMessage("Fido 'public_key' must be provided"); + + RuleFor(c => c.CoseAlgorithmNumber) + .NotNull() + .WithMessage("Fido 'public_key_algorithm' number must be provided in a valid COSE algorithm number"); + + RuleFor(c => c.Base64ClientData) + .NotEmpty() + .WithMessage("Fido 'client_data' must be provided") + .MaximumLength(4096); + + RuleFor(c => c.Base64AuthenticatorData) + .NotEmpty() + .WithMessage("Fido 'authenticator_data' must be provided") + .MaximumLength(4096); + + RuleFor(c => c.Base64Attestation) + .NotEmpty() + .WithMessage("Fido 'attestation' must be provided") + .MaximumLength(4096); + + } + + } + + internal sealed class FidoClientDataJsonValidtor : AbstractValidator<FidoClientDataJson> + { + public FidoClientDataJsonValidtor() + { + RuleFor(c => c.Base64Challenge) + .NotEmpty() + .WithMessage("Fido 'challenge' must be provided") + .MaximumLength(4096); + + RuleFor(c => c.Origin) + .NotEmpty() + .WithMessage("Fido 'origin' must be provided") + .MaximumLength(1024); + + RuleFor(c => c.Type) + .NotEmpty() + .WithMessage("Fido 'type' must be provided") + .MaximumLength(64); + } + } +}
\ 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 6e3653e..faad34b 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs @@ -27,6 +27,7 @@ using System.Net; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using System.Collections.Generic; using System.Security.Cryptography; using System.Text.Json.Serialization; @@ -44,6 +45,8 @@ using VNLib.Plugins.Essentials.Accounts.Validators; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; using static VNLib.Plugins.Essentials.Statics; +using VNLib.Plugins.Essentials.Accounts.MFA.Totp; +using VNLib.Plugins.Essentials.Accounts.MFA.Fido; /* @@ -73,7 +76,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private static readonly LoginMessageValidation LmValidator = new(); - private readonly MFAConfig MultiFactor; + private readonly MfaAuthManager MultiFactor; private readonly IUserManager Users; private readonly FailedLoginLockout _lockout; @@ -84,10 +87,25 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints uint maxLogins = config["max_login_attempts"].GetUInt32(); InitPathAndLog(path, pbase.Log); - + + MFAConfig conf = pbase.GetConfigElement<MFAConfig>(); Users = pbase.GetOrCreateSingleton<UserManager>(); - MultiFactor = pbase.GetConfigElement<MFAConfig>(); _lockout = new(maxLogins, duration); + + + List<IMfaProcessor> proc = []; + + if(conf.TOTPEnabled) + { + proc.Add(new TotpAuthProcessor(conf.TOTPConfig!)); + } + + if(conf.FIDOEnabled) + { + proc.Add(new FidoMfaProcessor(conf.FIDOConfig!)); + } + + MultiFactor = new(conf, [.. proc]); } protected override ERRNO PreProccess(HttpEntity entity) @@ -104,14 +122,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VirtualClose(entity, HttpStatusCode.Conflict); } - //If mfa is enabled, allow processing via mfa - if (MultiFactor.FIDOEnabled || MultiFactor.TOTPEnabled) + /* + * To continue an mfa upgrade, the client must send + * an mfa query argument to continue the upgrade process + */ + if (MultiFactor.Armed) { if (entity.QueryArgs.ContainsKey("mfa")) { return await ProcessMfaAsync(entity); } } + return await ProccesLoginAsync(entity); } @@ -165,7 +187,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //If login return true, the response has been set and we should return - if (LoginUser(entity, loginMessage, user, webm)) + if (LoginOrMfaConnection(entity, loginMessage, user, webm)) { goto Cleanup; } @@ -191,12 +213,11 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { //Validate password against store ERRNO valResult = await Users.ValidatePasswordAsync(user, login.Password!, PassValidateFlags.None, cancellation); - - //Valid results are greater than 0; + return valResult == UserPassValResult.Success; } - private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) + private bool LoginOrMfaConnection(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) { //Only allow active users if (user.Status != UserStatus.Active) @@ -211,22 +232,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints try { - //get the new upgrade jwt string - MfaUpgradeMessage? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage); - /* - * Mfa is essentially indempodent, the session stores the last upgrade key, so - * if this method is continually called, new mfa tokens will be generated. + * Determine if the user uses MFA to guard their account. If so + * force an MFA upgrade before allowing the user to login */ - - //if message is null, mfa was not enabled or could not be prepared - if (message.HasValue) + if (MultiFactor.HasMfaEnabled(user)) { - //Store the base64 signature - entity.Session.MfaUpgradeSecret(message.Value.SessionKey); - - //send challenge message to client - webm.Result = message.Value.ClientJwt; + //the upgrade message + webm.Result = MultiFactor.GetChallengeMessage(entity, user, loginMessage); webm.MultiFactorUpgrade = true; } else @@ -278,31 +291,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } - //Recover upgrade jwt - string? upgradeJwt = request.RootElement.GetPropString("upgrade"); - - if (webm.Assert(upgradeJwt != null, "Missing required upgrade data")) - { - return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); - } - - //Recover stored signature - string? storedSig = entity.Session.MfaUpgradeSecret(); - - if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE)) - { - return VirtualOk(entity, webm); - } - - //Recover upgrade data from upgrade message - MFAUpgrade? upgrade = MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig); + MfaChallenge? upgrade = MultiFactor.GetChallengeData(entity, request); + /* + * Upgrade may be null if it is not valid, not correctly formatted, + * expired, and so on. We cannot leak information about the upgrade + * request to the client, so return a generic error message + */ if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE)) { return VirtualOk(entity, webm); } - //recover user account using IUser? user = await Users.GetUserFromUsernameAsync(upgrade.UserName!); if (webm.Assert(user != null, MFA_ERROR_MESSAGE)) @@ -311,81 +311,55 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } bool locked = _lockout.CheckOrClear(user, entity.RequestedTimeUtc); - - //Make sure the account has not been locked out + if (webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE)) { //Locked, so clear stored signature - entity.Session.MfaUpgradeSecret(null); + MultiFactor.InvalidateUpgrade(entity); + } + else if (MultiFactor.VerifyResponse(entity, upgrade, user, request)) + { + /* + * ################################################### + * + * AUTHORIZATION ZONE + * + * Connection will be elevated to authorized + * this is a successful login! + * + * #################################################### + */ + + MultiFactor.InvalidateUpgrade(entity); + + /* + * Time to authorize the user now. This will cause state changs + * to the client session, and user account. The user + * is now authorized to use the session. + */ + entity.GenerateAuthorization(upgrade, user, webm); + + webm.Result = new AccountData() + { + EmailAddress = user.EmailAddress, + }; + + webm.Success = true; + + Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); } else { - //process mfa login - LoginMfa(entity, user, request, upgrade, webm); + webm.Result = "Please check your input and try again."; } - //Update user on clean process + //Flush any changes to the user store await user.ReleaseAsync(); //Close rseponse return VirtualOk(entity, webm); } - private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm) - { - //Recover the user's local time - if(!request.RootElement.TryGetProperty("localtime", out JsonElement ltEl) - && ltEl.TryGetDateTimeOffset(out DateTimeOffset localTime)) - { - webm.Result = MFA_ERROR_MESSAGE; - return; - } - - //Check mode - switch (upgrade.Type) - { - case MFAType.TOTP: - { - //get totp code from request - uint code = request.RootElement.GetProperty("code").GetUInt32(); - - //Verify totp code - if (!MultiFactor.VerifyTOTP(user, code)) - { - webm.Result = "Please check your code."; - - //Increment flc and update the user in the store - _lockout.Increment(user, entity.RequestedTimeUtc); - return; - } - //Valid, complete - } - break; - default: - webm.Result = MFA_ERROR_MESSAGE; - return; - } - - //SUCCESSFUL LOGIN - - //Wipe session signature - entity.Session.MfaUpgradeSecret(null); - - //Elevate the login status of the session to reflect the user's status - entity.GenerateAuthorization(upgrade, user, webm); - - //Send the Username (since they already have it) - webm.Result = new AccountData() - { - EmailAddress = user.EmailAddress, - }; - - webm.Success = true; - - //Write to log - Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); - } - private sealed class MfaUpgradeWebm : ValErrWebMessage { diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs index 2e102a3..f31334c 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -40,9 +40,13 @@ using VNLib.Plugins.Extensions.Validation; using VNLib.Plugins.Essentials.Endpoints; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Essentials.Accounts.MFA.Otp; +using VNLib.Plugins.Essentials.Accounts.MFA.Totp; +using VNLib.Plugins.Essentials.Accounts.MFA.Fido; namespace VNLib.Plugins.Essentials.Accounts.Endpoints { + [ConfigurationName("mfa_endpoint")] internal sealed class MFAEndpoint : ProtectedWebEndpoint { @@ -50,12 +54,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private const string CHECK_PASSWORD = "Please check your password"; private readonly IUserManager Users; - private readonly MFAConfig? MultiFactor; + private readonly MFAConfig MultiFactor; public MFAEndpoint(PluginBase pbase, IConfigScope config) - { - string? path = config["path"].GetString(); - InitPathAndLog(path, pbase.Log); + { + InitPathAndLog( + path: config.GetRequiredProperty("path", p => p.GetString()!), + log: pbase.Log.CreateScope("Mfa-Endpoint") + ); Users = pbase.GetOrCreateSingleton<UserManager>(); MultiFactor = pbase.GetConfigElement<MFAConfig>(); @@ -67,21 +73,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Load the MFA entry for the user using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); - - //Set the TOTP flag if set - if (user?.MFATotpEnabled() == true) + + if (user?.TotpEnabled() == true) { enabledModes[0] = "totp"; } - - //TODO Set fido flag if enabled - if (!string.IsNullOrWhiteSpace("")) + + if (user?.FidoEnabled() == true) { enabledModes[1] = "fido"; } - - //PKI enabled - if (user?.PKIEnabled() == true) + + if (user?.OtpAuthEnabled() == true) { enabledModes[2] = "pki"; } @@ -116,12 +119,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VirtualOk(entity, webm); } - //Make sure mfa is loaded - if (webm.Assert(MultiFactor != null, "MFA is not enabled on this server")) - { - return VirtualOk(entity, webm); - } - //Get the user entry using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); @@ -183,18 +180,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //get the request using JsonDocument? request = await entity.GetJsonFromFileAsync(); - if (webm.Assert(request != null, "Invalid request.")) + if (webm.Assert(request != null, "Invalid request")) { return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } string? mfaType = request.RootElement.GetProperty("type").GetString(); - - //get the user + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); - if (user == null) + + if (webm.Assert(user != null, "User does not exist")) { - return VfReturnType.NotFound; + return VirtualClose(entity, webm, HttpStatusCode.NotFound); } /* @@ -218,30 +215,35 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Check for totp disable - if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase)) + if (string.Equals("totp", mfaType, StringComparison.OrdinalIgnoreCase)) { - //Clear the TOTP secret to disable it - user.MFASetTOTPSecret(null); - - //write changes - await user.ReleaseAsync(); + user.TotpDisable(); + webm.Result = "Successfully disabled your TOTP authentication"; webm.Success = true; } - else if ("fido".Equals(mfaType, StringComparison.OrdinalIgnoreCase)) + else if (string.Equals("fido", mfaType, StringComparison.OrdinalIgnoreCase)) { - //Clear webauthn changes - - //write changes - await user.ReleaseAsync(); + user.FidoDisable(); + webm.Result = "Successfully disabled your FIDO authentication"; webm.Success = true; } + else if(string.Equals("pkotp", mfaType, StringComparison.OrdinalIgnoreCase)) + { + user.OtpDisable(); + + webm.Result = "Successfully disabled your OTP authentication"; + webm.Success = true; + } else { webm.Result = "Invalid MFA type"; } + //write changes (will do nothing if no changes were made) + await user.ReleaseAsync(); + //Must write response while password is in scope return VirtualOk(entity, webm); } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs index 60c99e3..b274f5f 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs @@ -38,6 +38,7 @@ using VNLib.Plugins.Essentials.Accounts.MFA; using VNLib.Plugins.Extensions.Validation; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Essentials.Accounts.MFA.Totp; namespace VNLib.Plugins.Essentials.Accounts.Endpoints { @@ -134,7 +135,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Check if totp is enabled - if (mFAConfig.TOTPEnabled && user.MFATotpEnabled()) + if (mFAConfig.TOTPEnabled && user.TotpEnabled()) { //TOTP code is required if (webm.Assert(pwReset.TotpCode.HasValue, "TOTP is enabled on this user account, you must enter your TOTP code.")) diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs index b88dc11..bda5898 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs @@ -43,13 +43,14 @@ 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; +using VNLib.Plugins.Essentials.Accounts.MFA.Otp; namespace VNLib.Plugins.Essentials.Accounts.Endpoints { + [ConfigurationName("pki_auth_endpoint")] internal sealed class PkiLoginEndpoint : UnprotectedWebEndpoint { @@ -66,9 +67,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private static IValidator<AuthenticationInfo> AuthValidator { get; } = AuthenticationInfo.GetValidator(); /// <summary> - /// A validator used to validate <see cref="PkiAuthPublicKey"/> instances + /// A validator used to validate <see cref="OtpAuthPublicKey"/> instances /// </summary> - public static IValidator<PkiAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator(); + public static IValidator<OtpAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator(); private readonly JwtEndpointConfig _config; private readonly IUserManager _users; @@ -176,7 +177,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //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.OtpVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE)) { //increment flc on invalid signature _lockout.Increment(user, entity.RequestedTimeUtc); @@ -254,7 +255,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Get the uesr's stored keys - webm.Result = user.PkiGetAllPublicKeys(); + webm.Result = user.OtpGetAllPublicKeys(); webm.Success = true; return VirtualOk(entity, webm); @@ -276,7 +277,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints ValErrWebMessage webm = new(); //Get the request body - PkiAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<PkiAuthPublicKey>(); + OtpAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<OtpAuthPublicKey>(); if(webm.Assert(pubKey != null, "The request message is not valid")) { @@ -304,6 +305,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VirtualOk(entity, webm); } + //Make sure there is enough room to store another key + if(webm.Assert(user.OtpCanAddKey(), "Cannot add another public key to your account")) + { + return VirtualOk(entity, webm); + } + try { //Try to get the ECDA instance to confirm the key data could be recovered properly @@ -323,7 +330,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Update user's key, or add it if it doesn't exist - user.PKIAddPublicKey(pubKey); + user.OtpAddPublicKey(pubKey); //publish changes await user.ReleaseAsync(); @@ -368,13 +375,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints if(entity.QueryArgs.TryGetValue("id", out string? keyId)) { //Remove only the specified key - user.PKIRemovePublicKey(keyId); + user.OtpRemovePublicKey(keyId); webm.Result = "You have successfully removed the key from your account"; } else { //Delete all keys - user.PKISetPublicKeys(null); + user.OtpSetPublicKeys(null); webm.Result = "You have successfully disabled PKI login"; } @@ -535,9 +542,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } } - private static IValidator<PkiAuthPublicKey> GetKeyValidator() + private static IValidator<OtpAuthPublicKey> GetKeyValidator() { - InlineValidator<PkiAuthPublicKey> val = new(); + InlineValidator<OtpAuthPublicKey> val = new(); val.RuleFor(a => a.KeyType) .NotEmpty() diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json index 8a345c5..173ed5b 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json @@ -42,6 +42,10 @@ "enable_key_update": true }, + "fido_endpoint": { + "path": "/account/fido" + }, + //If mfa is defined, configures mfa enpoints and enables mfa logins "mfa": { "upgrade_expires_secs": 180, @@ -59,14 +63,14 @@ "fido": { "challenge_size": 64, - "attestation": "none", "timeout": 60000, - "site_name": "vaughnnugent.com", + "attestation_type": "none", + "site_name": "localhost", - "authenticatorSelection": { + "authenticator_selection": { "authenticatorAttachment": "cross-platform", "requireResidentKey": false, - "userVerification": "required" + "userVerification": "preferred" } } }, diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs new file mode 100644 index 0000000..e0160e1 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs @@ -0,0 +1,62 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: CoseEncodings.cs +* +* CoseEncodings.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/. +*/ + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + internal static class CoseEncodings + { + public static int GetCodeFromAlg(string algName) + { + return algName switch + { + "ES256" => -7, + "ES384" => -35, + "ES512" => -36, + _ => 0 + }; + } + + public static string GetAlgFromCode(int code) + { + return code switch + { + -7 => "ES256", + -35 => "ES384", + -36 => "ES512", + _ => string.Empty + }; + } + + public static string GetCurveFromCode(int code) + { + return code switch + { + -7 => "P-256", + -35 => "P-384", + -36 => "P-521", + _ => string.Empty + }; + } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs new file mode 100644 index 0000000..aedb2a7 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs @@ -0,0 +1,50 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoConfig.cs +* +* FidoConfig.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; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + + internal sealed class FidoAuthenticatedRequest + { + /// <summary> + /// Base64 encoded device ID + /// </summary> + [JsonPropertyName("id")] + public string Base64Id { get; set; } = string.Empty; + + /// <summary> + /// The device attachment type + /// </summary> + [JsonPropertyName("authenticatorAttachment")] + public string? Attachment { get; set; } + + /// <summary> + /// The device registration response data + /// </summary> + [JsonPropertyName("response")] + public FidoAuthenticatorResponse? Response { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs new file mode 100644 index 0000000..8f0ac7f --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs @@ -0,0 +1,50 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoConfig.cs +* +* FidoConfig.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; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + internal sealed class FidoAuthenticatorResponse + { + [JsonPropertyName("id")] + public string DeviceId { get; set; } = string.Empty; + + [JsonPropertyName("publicKey")] + public string? Base64PublicKey { get; set; } + + [JsonPropertyName("publicKeyAlgorithm")] + public int? CoseAlgorithmNumber { get; set; } + + [JsonPropertyName("clientDataJSON")] + public string? Base64ClientData { get; set; } + + [JsonPropertyName("authenticatorData")] + public string? Base64AuthenticatorData { get; set; } + + [JsonPropertyName("attestationObject")] + public string? Base64Attestation { get; set; } + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs index 301113c..8699537 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs @@ -28,8 +28,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido { internal sealed class FidoAuthenticatorSelection { - [JsonPropertyName("requireResidentKey")] - public bool RequireResidentKey { get; set; } = false; + [JsonPropertyName("residentKey")] + public string? RequireResidentKey { get; set; } = "discouraged"; [JsonPropertyName("authenticatorAttachment")] public string? AuthenticatorAttachment { get; set; } = "cross-platform"; diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs new file mode 100644 index 0000000..e6b567d --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoConfig.cs +* +* FidoConfig.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; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + internal sealed class FidoClientDataJson + { + [JsonPropertyName("challenge")] + public string? Base64Challenge { get; set; } + + [JsonPropertyName("origin")] + public string? Origin { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs new file mode 100644 index 0000000..3f3e930 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs @@ -0,0 +1,85 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoConfig.cs +* +* FidoConfig.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 FluentValidation; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + + internal sealed class FidoConfig + { + + [JsonPropertyName("challenge_size")] + public int ChallangeSize { get; set; } + + [JsonPropertyName("timeout")] + public int Timeout { get; set; } + + [JsonPropertyName("site_name")] + public string? SiteName { get; set; } + + [JsonPropertyName("attestation_type")] + public string? AttestationType { get; set; } + + [JsonPropertyName("authenticator_selection")] + public FidoAuthenticatorSelection? FIDOAuthSelection { get; set; } + + [JsonPropertyName("transport")] + public string[] Transports { get; set; } = ["usb", "nfc", "ble"]; + + internal static IValidator<FidoConfig> GetValidator() + { + InlineValidator<FidoConfig> val = new(); + + val.RuleFor(c => c.ChallangeSize) + .InclusiveBetween(1, 4096) + .WithMessage("Fido 'challenge_size' must be between 1 and 4096 bytes"); + + val.RuleFor(c => c.Timeout) + .InclusiveBetween(1, int.MaxValue) + .WithMessage("Fido 'timeout' must be between 1 and 600 seconds"); + + val.RuleFor(c => c.SiteName) + .NotEmpty() + .WithMessage("Fido 'site_name' must be provided"); + + val.RuleFor(c => c.AttestationType) + .NotEmpty() + .WithMessage("Fido 'attestation_type' must be provided"); + + val.RuleFor(c => c.FIDOAuthSelection) + .NotNull() + .WithMessage("Fido 'authenticator_selection' must be provided"); + + val.RuleFor(c => c.Transports) + .NotEmpty() + .ForEach(p => p.NotEmpty()) + .WithMessage("Fido 'transport' must be provided"); + + return val; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs new file mode 100644 index 0000000..2d7e01e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoDeviceCredential.cs +* +* FidoDeviceCredential.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; + + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + public sealed class FidoDeviceCredential + { + [JsonPropertyName("n")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Base64UrlId { get; set; } + + [JsonPropertyName("pk")] + public string Base64PublicKey { get; set; } + + [JsonPropertyName("alg")] + public int CoseAlgId { get; set; } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs new file mode 100644 index 0000000..63aa46e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs @@ -0,0 +1,181 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: LoginEndpoint.cs +* +* LoginEndpoint.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.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +using VNLib.Plugins.Essentials.Users; +using VNLib.Hashing.IdentityUtility; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Hashing; +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + internal sealed class FidoMfaProcessor(FidoConfig conf) : IMfaProcessor + { + const string JwtClaimKey = "fido"; + + ///<inheritdoc/> + public MFAType Type => MFAType.TOTP; + + ///<inheritdoc/> + public void ExtendUpgradePayload(in JwtPayload message, IUser user) + { + FidoDeviceCredential[]? devices = user.FidoGetAllCredentials(); + + if(devices == null || devices.Length == 0) + { + return; + } + + using UnsafeMemoryHandle<byte> challBuffer = MemoryUtil.UnsafeAlloc(conf.ChallangeSize, true); + + RandomHash.GetRandomBytes(challBuffer.Span); + + message.AddClaim( + claim: JwtClaimKey, + value: GetChallengeData(challBuffer.Span, devices) + ); + } + + ///<inheritdoc/> + public bool MethodEnabledForUser(IUser user) => user.FidoEnabled(); + + ///<inheritdoc/> + public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result) + { + FidoUpgradeResponse? fidoResponse = result.RootElement.GetProperty("fido") + .Deserialize<FidoUpgradeResponse>(); + + if (fidoResponse is null) + { + return false; + } + + + + return false; + } + + private ERRNO RecoverFidoChallenge(JsonDocument chalUpgrade, Span<byte> outBuffer) + { + /* + * When this function is called it must be assumed that the mfa token signature + * was verified so it doesn't need to be checked again. + * + * The only data we need to recover from the upgrade is the fido challenge data. + * to verify it's signature. + */ + + string? chalJwtData = chalUpgrade.RootElement.GetPropString("mfa"); + if (string.IsNullOrWhiteSpace(chalJwtData)) + { + return 0; + } + + using JsonWebToken jwt = JsonWebToken.Parse(chalJwtData); + + using JsonDocument chalDoc = jwt.GetPayload(); + + string challenge = chalDoc.RootElement.GetProperty(JwtClaimKey) + .GetProperty("challenge") + .GetString()!; + + return VnEncoding.Base64UrlDecode(challenge, outBuffer); + } + + private FidoDevUpgradeJson GetChallengeData(ReadOnlySpan<byte> challenge, FidoDeviceCredential[] devices) + { + return new FidoDevUpgradeJson + { + Base64UrlChallange = VnEncoding.ToBase64UrlSafeString(challenge, false), + + Timeout = conf.Timeout, + + Credentials = devices.Select(p => new CredentialInfoJson + { + Base64UrlId = p.Base64UrlId, + Transports = conf.Transports, + Type = "public-key" + }).ToArray(), + }; + } + + sealed class FidoDevUpgradeJson + { + [JsonPropertyName("challenge")] + public string Base64UrlChallange { get; set; } = string.Empty; + + [JsonPropertyName("allowCredentials")] + public CredentialInfoJson[] Credentials { get; set; } = Array.Empty<CredentialInfoJson>(); + + [JsonPropertyName("timeout")] + public int Timeout { get; set; } + } + + sealed class CredentialInfoJson + { + [JsonPropertyName("id")] + public string Base64UrlId { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = "public-key"; + + [JsonPropertyName("transports")] + public string[] Transports { get; set; } = Array.Empty<string>(); + } + } + + internal sealed class FidoUpgradeResponse + { + [JsonPropertyName("id")] + public string Base64UrlId { get; set; } = string.Empty; + + [JsonPropertyName("authenticatorAttachment")] + public string? Attachment { get; set; } + + [JsonPropertyName("response")] + public FidoAuthenticatorAssertionResponse? Response { get; set; } + } + + internal sealed class FidoAuthenticatorAssertionResponse + { + [JsonPropertyName("authenticatorData")] + public string Base64UrlAuthData { get; set; } = string.Empty; + + [JsonPropertyName("clientDataJSON")] + public string Base64UrlClientData { get; set; } = string.Empty; + + [JsonPropertyName("signature")] + public string Base64UrlSignature { get; set; } = string.Empty; + + [JsonPropertyName("userHandle")] + public string? Base64UrlUserHandle { get; set; } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs index 0bdd563..8875e12 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs @@ -26,10 +26,10 @@ using System.Text.Json.Serialization; namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido { - internal sealed class FidoPubkeyAlgorithm + internal sealed class FidoPubkeyAlgorithm(int algId) { [JsonPropertyName("alg")] - public int AlgId { get; set; } + public int AlgId { get; set; } = algId; [JsonPropertyName("type")] public string Type { get; set; } = "public-key"; diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs index 4dfa036..9df1461 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs @@ -42,7 +42,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido public FidoRelyingParty RelyingParty { get; set; } = new(); [JsonPropertyName("attestation")] - public string AttestationType { get; set; } = "none"; + public string? AttestationType { get; set; } = "none"; [JsonPropertyName("user")] public FidoUserData User { get; set; } = new(); @@ -51,6 +51,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido public FidoPubkeyAlgorithm[]? PubKeyCredParams { get; set; } [JsonPropertyName("authenticatorSelection")] - public FidoAuthenticatorSelection AuthSelection { get; set; } = new(); + public FidoAuthenticatorSelection? AuthSelection { get; set; } = new(); } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs new file mode 100644 index 0000000..8c605dc --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: FidoConfig.cs +* +* FidoConfig.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; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + internal sealed class FidoUserCredential + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("tp")] + public string Algorithm { get; set; } = string.Empty; + + [JsonPropertyName("pk")] + public string PublicKey { get; set; } = string.Empty; + + [JsonPropertyName("alg")] + public int AlgorithmId { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs index aadef29..3c64c7d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs @@ -22,23 +22,10 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -using System; -using System.Buffers.Binary; -using System.Formats.Cbor; using System.Text.Json.Serialization; -using VNLib.Hashing.IdentityUtility; - namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido { - internal sealed class FidoAuthenticatorResponse - { - [JsonPropertyName("client_data")] - public string? Base64ClientDataJson { get; set; } - - [JsonPropertyName("attestation_object")] - public string? Base64AttestationObject { get; set; } - } internal sealed class FidoUserData { diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs new file mode 100644 index 0000000..f6bb748 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs @@ -0,0 +1,136 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: UserFidoMfaExtensions.cs +* +* UserFidoMfaExtensions.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.Linq; + +using VNLib.Plugins.Essentials.Users; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido +{ + /// <summary> + /// Provides Fido/Webauthn authentication extension methods for users + /// </summary> + public static class UserFidoMfaExtensions + { + public const string FidoUserStoreKey = "mfa.fido"; + + public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object + public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded + + /// <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 FidoEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[FidoUserStoreKey]); + + /// <summary> + /// Disables all Fido authentication for the current user + /// </summary> + /// <param name="user"></param> + public static void FidoDisable(this IUser user) => user[FidoUserStoreKey] = null!; + + /// <summary> + /// Attempts to determine if another fido key can be encoded and stored in the + /// user's account object. This assumes that the key is roughly 320 bytes + /// when encoded. + /// </summary> + /// <param name="user"></param> + /// <returns>True if there is enough key space to store another key, false otherwise</returns> + public static bool FidoCanAddKey(this IUser user) + { + string rawData = user[FidoUserStoreKey]; + if (string.IsNullOrWhiteSpace(rawData)) + { + return true; + } + + return rawData.Length + AssumedKeySize < MaxEncodedSize; + } + + /// <summary> + /// Stores an array of public keys in the user's account object + /// </summary> + /// <param name="user"></param> + /// <param name="creds">The array of device credentials to store for the user</param> + public static void FidoSetCredentials(this IUser user, FidoDeviceCredential[]? creds) + => UserEnocdedData.Encode(user, FidoUserStoreKey, creds); + + /// <summary> + /// Gets all public keys stored in the user's account object + /// </summary> + /// <param name="user"></param> + /// <returns>The array of device credentials if they exist</returns> + public static FidoDeviceCredential[]? FidoGetAllCredentials(this IUser user) + => UserEnocdedData.Decode<FidoDeviceCredential[]>(user, FidoUserStoreKey); + + /// <summary> + /// Removes a single pki key by it's id + /// </summary> + /// <param name="user"></param> + /// <param name="credId">The id of the credential to remove</param> + public static void FidoRemoveCredential(this IUser user, string credId) + { + FidoDeviceCredential[]? keys = user.FidoGetAllCredentials(); + if (keys == null) + { + return; + } + + //Remove the key and store a new array without it + + FidoSetCredentials( + user: user, + creds: keys.Where(k => !string.Equals(credId, k.Base64UrlId, StringComparison.Ordinal)).ToArray() + ); + } + + /// <summary> + /// Adds a single pki key to the user's account object, or overwrites + /// and existing key with the same id + /// </summary> + /// <param name="user"></param> + /// <param name="key">The key to add to the list of user-keys</param> + public static void FidoAddCredential(this IUser user, FidoDeviceCredential key) + { + FidoDeviceCredential[]? keys = user.FidoGetAllCredentials(); + + if (keys == null) + { + //Add a single key if none exist + keys = [key]; + } + else + { + //remove the key if it already exists, then append the new key + keys = keys.Where(k => !string.Equals(key.Base64UrlId, k.Base64UrlId, StringComparison.Ordinal)) + .Append(key) + .ToArray(); + } + + user.FidoSetCredentials(keys); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs new file mode 100644 index 0000000..95679c7 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: IMfaProcessor.cs +* +* IMfaProcessor.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; + +using VNLib.Hashing.IdentityUtility; +using VNLib.Plugins.Essentials.Users; + + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + internal interface IMfaProcessor + { + MFAType Type { get; } + + bool MethodEnabledForUser(IUser user); + + void ExtendUpgradePayload(in JwtPayload message, IUser user); + + bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result); + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs index 9dfd183..e44006a 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -27,9 +27,9 @@ using System.Text.Json.Serialization; using FluentValidation; -using VNLib.Hashing; -using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Essentials.Accounts.MFA.Fido; +using VNLib.Plugins.Essentials.Accounts.MFA.Totp; +using VNLib.Plugins.Extensions.Loading; namespace VNLib.Plugins.Essentials.Accounts.MFA { @@ -53,6 +53,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA .GreaterThanOrEqualTo(8) .WithMessage("You must configure a signing key size of 8 bytes or larger"); + val.RuleFor(c => c.FIDOConfig) + .SetValidator(FidoConfig.GetValidator()!) + .When(c => c.FIDOConfig != null); + + val.RuleFor(c => c.TOTPConfig) + .SetValidator(TOTPConfig.GetValidator()!) + .When(c => c.TOTPConfig != null); + return val; } @@ -61,18 +69,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA [JsonPropertyName("totp")] public TOTPConfig? TOTPConfig { get; set; } - [JsonIgnore] - public bool TOTPEnabled => TOTPConfig?.IssuerName != null; - [JsonPropertyName("fido")] public FidoConfig? FIDOConfig { get; set; } - [JsonIgnore] - public bool FIDOEnabled => FIDOConfig?.FIDOSiteName != null; - - [JsonIgnore] - public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120); - [JsonPropertyName("upgrade_expires_secs")] public int UpgradeExpSeconds { @@ -82,113 +81,20 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA [JsonPropertyName("nonce_size")] public int NonceLenBytes { get; set; } = 16; + [JsonPropertyName("upgrade_size")] public int UpgradeKeyBytes { get; set; } = 32; - - - public void Validate() - { - //Validate the current confige before child configs - _validator.ValidateAndThrow(this); - - TOTPConfig?.Validate(); - FIDOConfig?.Validate(); - } - } - - internal class TOTPConfig : IOnConfigValidation - { - private static IValidator<TOTPConfig> GetValidator() - { - InlineValidator<TOTPConfig> val = new(); - - val.RuleFor(c => c.IssuerName) - .NotEmpty(); - - val.RuleFor(c => c.PeriodSec) - .InclusiveBetween(1, 600); - - val.RuleFor(c => c.TOTPAlg) - .Must(a => a != HashAlg.None) - .WithMessage("TOTP Algorithim name must not be NONE"); - - val.RuleFor(c => c.TOTPDigits) - .GreaterThan(1) - .WithMessage("You should have more than 1 digit for a totp code"); - - //We dont neet to check window steps, the user may want to configure 0 or more - val.RuleFor(c => c.TOTPTimeWindowSteps); - - val.RuleFor(c => c.TOTPSecretBytes) - .GreaterThan(8) - .WithMessage("You should configure a larger TOTP secret size for better security"); - - return val; - } [JsonIgnore] - private static IValidator<TOTPConfig> _validator { get; } = GetValidator(); + public bool TOTPEnabled => TOTPConfig?.Enabled == true; - [JsonPropertyName("issuer")] - public string? IssuerName { get; set; } - - [JsonPropertyName("period_sec")] - public int PeriodSec - { - get => (int)TOTPPeriod.TotalSeconds; - set => TOTPPeriod = TimeSpan.FromSeconds(value); - } [JsonIgnore] - public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30); - + public bool FIDOEnabled => FIDOConfig != null; - [JsonPropertyName("algorithm")] - public string AlgName - { - get => TOTPAlg.ToString(); - set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null)); - } [JsonIgnore] - public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1; - - [JsonPropertyName("digits")] - public int TOTPDigits { get; set; } = 6; - - [JsonPropertyName("secret_size")] - public int TOTPSecretBytes { get; set; } = 32; - - [JsonPropertyName("window_size")] - public int TOTPTimeWindowSteps { get; set; } = 1; - - public void Validate() - { - //Validate the current instance on the - _validator.ValidateAndThrow(this); - } - } - - internal class FidoConfig : IOnConfigValidation - { - private static IValidator<FidoConfig> GetValidator() - { - InlineValidator<FidoConfig> val = new(); - - - return val; - } - - private static IValidator<FidoConfig> _validator { get; } = GetValidator(); + public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120); - - public int FIDOChallangeSize { get; } - public int FIDOTimeout { get; } - public string? FIDOSiteName { get; } - public string? FIDOAttestationType { get; } - public FidoAuthenticatorSelection? FIDOAuthSelection { get; } - public void Validate() - { - _validator.ValidateAndThrow(this); - } + public void Validate() => _validator.ValidateAndThrow(this); } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs new file mode 100644 index 0000000..dc52c59 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs @@ -0,0 +1,248 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: MfaAuthManager.cs +* +* MfaAuthManager.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.Linq; +using System.Text.Json; +using System.Collections.Generic; + +using FluentValidation; + +using VNLib.Utils; +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + + internal sealed class MfaAuthManager(MFAConfig config, IMfaProcessor[] processors) + { + + public const string SESSION_SIG_KEY = "mfa.sig"; + private const HashAlg SigAlg = HashAlg.SHA256; + private static readonly byte[] UpgradeHeader = CompileJwtHeader(); + + public bool Armed => processors.Length > 0; + + /// <summary> + /// Determines if the user has any MFA methods enabled and + /// should continue with an MFA upgrade + /// </summary> + /// <param name="user">The user to upgrade the mfa request on</param> + /// <returns>True if the user has any MFA methods enabled</returns> + public bool HasMfaEnabled(IUser user) + { + return processors.Any(p => p.MethodEnabledForUser(user)); + } + + /// <summary> + /// Gets the upgrade message to send back to the client to + /// continue the MFA upgrade process + /// </summary> + /// <param name="entity">The connection to upgrade</param> + /// <param name="user">The user wishing to upgrade MFA methods</param> + /// <param name="login">The login message containing required client authentication data</param> + /// <returns>The encoded upgrade message to send to the client</returns> + public string GetChallengeMessage(HttpEntity entity, IUser user, LoginMessage login) + { + string clientJwt = string.Empty, secret = string.Empty; + + /* + * Upgrade tells the client what methods are suppoted by + * the server specific to a user. The client may choose + * to use any of the methods. + */ + MfaChallenge upgrade = new() + { + //Set totp upgrade type + Types = GetEnbaledTypesForUser(user), + + //Store login message details + UserName = login.UserName, + ClientId = login.ClientId, + PublicKey = login.ClientPublicKey, + ClientLocalLanguage = login.LocalLanguage, + }; + + GetUpgradeMessage(upgrade, ref clientJwt, ref secret); + + //Store the upgrade message in the session + SetUpgradeSecret(in entity.Session, secret); + + return clientJwt; + } + + /// <summary> + /// Recovers and validates a previously signed challenge message from the client + /// </summary> + /// <param name="entity">The entity requesting the completation</param> + /// <param name="result">The client's result of an mfa upgrade operation</param> + /// <returns>The </returns> + public MfaChallenge? GetChallengeData(HttpEntity entity, JsonDocument result) + { + //Recover upgrade jwt + string? upgradeJwt = result.RootElement.GetPropString("upgrade"); + string? storedSecret = GetUpgradeSecret(in entity.Session); + + if (string.IsNullOrEmpty(upgradeJwt) || string.IsNullOrEmpty(storedSecret)) + { + return null; + } + + //Recover upgrade data from upgrade message + return RecoverChallange(entity.RequestedTimeUtc, upgradeJwt, storedSecret); + } + + /// <summary> + /// Verifies the response from the client to the MFA upgrade request + /// and determines if the upgrade was successful + /// </summary> + /// <param name="entity"></param> + /// <param name="upgrade">The validated upgrade message returned by the client</param> + /// <param name="user">The user account to validate against</param> + /// <param name="result">The client's result message from the upgrade challenge</param> + /// <returns>True if the client successfully validated</returns> + public bool VerifyResponse(HttpEntity entity, MfaChallenge upgrade, IUser user, JsonDocument result) + { + string? desiredMfaType = entity.QueryArgs.GetValueOrDefault("mfa"); + + if (!Enum.TryParse(desiredMfaType, true, out MFAType desiredType)) + { + return false; + } + + //See if upgrade allows the desired type + if (!upgrade.Types.Contains(desiredType)) + { + return false; + } + + //Get the processor for the desired type + IMfaProcessor? processor = processors.FirstOrDefault(p => p.Type == desiredType); + + if (processor == null) + { + return false; + } + + //Verify the response using the desired processor + return processor.VerifyResponse(upgrade, user, result); + } + + public void InvalidateUpgrade(HttpEntity entity) + { + SetUpgradeSecret(in entity.Session, null); + } + + private MFAType[] GetEnbaledTypesForUser(IUser user) + { + return processors.Where(p => p.MethodEnabledForUser(user)) + .Select(static p => p.Type) + .ToArray(); + } + + private static void SetUpgradeSecret(ref readonly SessionInfo session, string? base32Signature) + => session[SESSION_SIG_KEY] = base32Signature!; + + private static string? GetUpgradeSecret(ref readonly SessionInfo session) + => session[SESSION_SIG_KEY]; + + private MfaChallenge? RecoverChallange(DateTimeOffset now, string upgradeJwtString, string base32Secret) + { + using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString); + + byte[] secret = VnEncoding.FromBase32String(base32Secret)!; + + try + { + if (!jwt.Verify(secret, SigAlg)) + { + return null; + } + } + finally + { + //Erase secret + MemoryUtil.InitializeBlock(secret); + } + + using JsonDocument doc = jwt.GetPayload(); + + //Recover issued at time + long iatMs = doc.RootElement.GetProperty("iat").GetInt64(); + DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(iatMs); + + if (iat.Add(config.UpgradeValidFor) < now) + { + //expired + return null; + } + + //Recover the upgrade message + return doc.RootElement.GetProperty("upgrade").Deserialize<MfaChallenge>(); + } + + private void GetUpgradeMessage(MfaChallenge upgrade, ref string clientMessage, ref string secret) + { + //Add some random entropy to the upgrade message, to help prevent forgery + string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes); + byte[] sigKey = RandomHash.GetRandomBytes(config.UpgradeKeyBytes); + + using JsonWebToken upgradeJwt = new(); + + upgradeJwt.WriteHeader(UpgradeHeader); + + string[] mfaTypes = upgrade.Types.Select(static t => t.ToString().ToLower(null)).ToArray(); + + upgradeJwt.InitPayloadClaim() + .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + .AddClaim("upgrade", upgrade) + .AddClaim("capabilities", mfaTypes) + .AddClaim("expires", config.UpgradeValidFor.TotalSeconds) + .AddClaim("a", entropy) + .CommitClaims(); + + upgradeJwt.Sign(sigKey, SigAlg); + + clientMessage = upgradeJwt.Compile(); + secret = VnEncoding.ToBase32String(sigKey); + } + + private static byte[] CompileJwtHeader() + { + Dictionary<string, string> header = new() + { + { "alg","HS256" }, + { "typ", "JWT" } + }; + return JsonSerializer.SerializeToUtf8Bytes(header); + } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs index e69088a..e06b78c 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -26,7 +26,7 @@ using System.Text.Json.Serialization; namespace VNLib.Plugins.Essentials.Accounts.MFA { - internal class MFAUpgrade : IClientSecInfo + internal class MfaChallenge : IClientSecInfo { /// <summary> /// The login's client id specifier @@ -41,8 +41,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// <summary> /// The <see cref="MFAType"/> of the upgrade request /// </summary> - [JsonPropertyName("type")] - public MFAType Type { get; set; } + [JsonPropertyName("types")] + public MFAType[] Types { get; set; } /// <summary> /// The a base64 encoded string of the user's /// public key diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs index a941852..2fd507d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs @@ -1,11 +1,11 @@ /* -* Copyright (c) 2023 Vaughn Nugent +* Copyright (c) 2024 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts -* File: PkiAuthPublicKey.cs +* File: OtpAuthPublicKey.cs * -* PkiAuthPublicKey.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger +* OtpAuthPublicKey.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 @@ -26,12 +26,12 @@ using System.Text.Json.Serialization; using VNLib.Hashing.IdentityUtility; -namespace VNLib.Plugins.Essentials.Accounts.MFA +namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp { /// <summary> /// A json serializable JWK format public key for PKI authentication /// </summary> - public record class PkiAuthPublicKey : IJsonWebKey + public record class OtpAuthPublicKey : IJsonWebKey { [JsonPropertyName("kid")] public string? KeyId { get; set; } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs new file mode 100644 index 0000000..97b1807 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs @@ -0,0 +1,167 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: UserPkiMfaExtensions.cs +* +* UserPkiMfaExtensions.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.Linq; +using System.Buffers; + +using VNLib.Utils.IO; +using VNLib.Utils.Extensions; +using VNLib.Hashing.IdentityUtility; +using VNLib.Plugins.Essentials.Users; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp +{ + + /// <summary> + /// Provides user extension methods for PKI specific MFA operations + /// </summary> + public static class UserOtpMfaExtensions + { + /// <summary> + /// The key used to store the user's encoded Otp public + /// keys in their account object + /// </summary> + public const string OtpUserStoreKey = "mfa.pki"; + + public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object + public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded + + /// <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 OtpAuthEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[OtpUserStoreKey]); + + /// <summary> + /// Disables PKI authentication for the current user + /// </summary> + /// <param name="user"></param> + public static void OtpDisable(this IUser user) => user[OtpUserStoreKey] = null!; + + /// <summary> + /// Attempts to determine if another key can be encoded and stored in the + /// user's account object. This assumes that the key is roughly 320 bytes + /// when encoded. + /// </summary> + /// <param name="user"></param> + /// <returns>True if there is enough key space to store another key, false otherwise</returns> + public static bool OtpCanAddKey(this IUser user) + { + string rawData = user[OtpUserStoreKey]; + if (string.IsNullOrWhiteSpace(rawData)) + { + return true; + } + + return rawData.Length + AssumedKeySize < MaxEncodedSize; + } + + /// <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 OtpVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId) + { + /* + * Since multiple keys can be stored, we need to recover the key that matches the desired key id + */ + OtpAuthPublicKey? pub = user.OtpGetAllPublicKeys()?.FirstOrDefault(p => string.Equals(keyId, p.KeyId, StringComparison.Ordinal)); + + if (pub == null) + { + return false; + } + + //verify the jwt + return jwt.VerifyFromJwk(pub); + } + + /// <summary> + /// Stores an array of public keys in the user's account object + /// </summary> + /// <param name="user"></param> + /// <param name="authKeys">The array of jwk format keys to store for the user</param> + public static void OtpSetPublicKeys(this IUser user, OtpAuthPublicKey[]? authKeys) + => UserEnocdedData.Encode(user, OtpUserStoreKey, authKeys); + + /// <summary> + /// Gets all public keys stored in the user's account object + /// </summary> + /// <param name="user"></param> + /// <returns>The array of public keys if the exist</returns> + public static OtpAuthPublicKey[]? OtpGetAllPublicKeys(this IUser user) + => UserEnocdedData.Decode<OtpAuthPublicKey[]>(user, OtpUserStoreKey); + + /// <summary> + /// Removes a single pki key by it's id + /// </summary> + /// <param name="user"></param> + /// <param name="keyId">The id of the key to remove</param> + public static void OtpRemovePublicKey(this IUser user, string keyId) + { + OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys(); + if (keys == null) + { + return; + } + + //Remove the key and store a new array without it + + user.OtpSetPublicKeys( + authKeys: keys.Where(k => !string.Equals(keyId, k.KeyId, StringComparison.Ordinal)).ToArray() + ); + } + + /// <summary> + /// Adds a single pki key to the user's account object, or overwrites + /// and existing key with the same id + /// </summary> + /// <param name="user"></param> + /// <param name="key">The key to add to the list of user-keys</param> + public static void OtpAddPublicKey(this IUser user, OtpAuthPublicKey key) + { + OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys(); + + if (keys == null) + { + //Add a single key if none exist + keys = [key]; + } + else + { + //remove the key if it already exists, then append the new key + keys = keys.Where(k => !string.Equals(key.KeyId, k.KeyId, StringComparison.Ordinal)) + .Append(key) + .ToArray(); + } + + user.OtpSetPublicKeys(keys); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs new file mode 100644 index 0000000..eba54fe --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs @@ -0,0 +1,100 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: TOTPConfig.cs +* +* TOTPConfig.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.Text.Json.Serialization; + +using FluentValidation; + +using VNLib.Hashing; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp +{ + internal sealed class TOTPConfig + { + [JsonPropertyName("issuer")] + public string? IssuerName { get; set; } + + [JsonPropertyName("period_sec")] + public int PeriodSec + { + get => (int)TOTPPeriod.TotalSeconds; + set => TOTPPeriod = TimeSpan.FromSeconds(value); + } + + [JsonPropertyName("algorithm")] + public string AlgName + { + get => TOTPAlg.ToString(); + set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null)); + } + + [JsonPropertyName("digits")] + public int TOTPDigits { get; set; } = 6; + + [JsonPropertyName("secret_size")] + public int TOTPSecretBytes { get; set; } = 32; + + [JsonPropertyName("window_size")] + public int TOTPTimeWindowSteps { get; set; } = 1; + + [JsonIgnore] + public bool Enabled => IssuerName != null; + + [JsonIgnore] + public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1; + + [JsonIgnore] + public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30); + + internal static IValidator<TOTPConfig> GetValidator() + { + InlineValidator<TOTPConfig> val = new(); + + val.RuleFor(c => c.IssuerName) + .NotEmpty(); + + val.RuleFor(c => c.PeriodSec) + .InclusiveBetween(1, 600); + + val.RuleFor(c => c.TOTPAlg) + .Must(a => a != HashAlg.None) + .WithMessage("TOTP Algorithim name must not be NONE"); + + val.RuleFor(c => c.TOTPDigits) + .GreaterThan(1) + .WithMessage("You should have more than 1 digit for a totp code"); + + //We dont neet to check window steps, the user may want to configure 0 or more + val.RuleFor(c => c.TOTPTimeWindowSteps); + + val.RuleFor(c => c.TOTPSecretBytes) + .GreaterThan(8) + .WithMessage("You should configure a larger TOTP secret size for better security"); + + return val; + } + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs new file mode 100644 index 0000000..393a745 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs @@ -0,0 +1,186 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: LoginEndpoint.cs +* +* LoginEndpoint.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.Linq; +using System.Text.Json; +using System.Diagnostics; + +using VNLib.Utils; +using VNLib.Hashing; +using VNLib.Utils.Memory; +using VNLib.Plugins.Essentials.Users; +using VNLib.Hashing.IdentityUtility; + + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp +{ + internal sealed class TotpAuthProcessor(TOTPConfig config) : IMfaProcessor + { + ///<inheritdoc/> + public MFAType Type => MFAType.TOTP; + + ///<inheritdoc/> + public bool MethodEnabledForUser(IUser user) => user.TotpEnabled(); + + ///<inheritdoc/> + public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result) + { + if (!result.RootElement.TryGetProperty("code", out JsonElement codeEl) + || codeEl.ValueKind != JsonValueKind.Number) + { + return false; + } + + return VerifyTOTP(user, codeEl.GetUInt32()); + } + + /// <summary> + /// Verfies the supplied TOTP code against the current user's totp codes + /// This method should not be used for verifying TOTP codes for authentication + /// </summary> + /// <param name="user">The user account to verify the TOTP code against</param> + /// <param name="code">The code to verify</param> + /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns> + /// <exception cref="FormatException"></exception> + /// <exception cref="OutOfMemoryException"></exception> + internal bool VerifyTOTP(IUser user, uint code) + { + //Get the base32 TOTP secret for the user and make sure its actually set + string base32Secret = user.TotpGetSecret(); + + if (string.IsNullOrWhiteSpace(base32Secret)) + { + return false; + } + + int length = base32Secret.Length; + bool isValid; + + if (length > 256) + { + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true); + + ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span); + + //Verify the TOTP using the decrypted secret + isValid = count && VerifyTotpCode(code, buffer.AsSpan(0, count)); + + MemoryUtil.InitializeBlock( + ref buffer.GetReference(), + buffer.IntLength + ); + } + else + { + Span<byte> buffer = stackalloc byte[base32Secret.Length]; + + ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer); + + //Verify the TOTP using the decrypted secret + isValid = count && VerifyTotpCode(code, buffer[..(int)count]); + + MemoryUtil.InitializeBlock(buffer); + } + + return isValid; + } + + private bool VerifyTotpCode(uint totpCode, ReadOnlySpan<byte> userSecret) + { + /* + * A basic attempt at a constant time TOTP verification, run + * the calculation a fixed number of times, regardless of the resutls + */ + bool codeMatches = false; + + //cache current time + DateTimeOffset currentUtc = DateTimeOffset.UtcNow; + + //Start the current window with the minimum window + int currenStep = -config.TOTPTimeWindowSteps; + + Span<byte> stepBuffer = stackalloc byte[sizeof(long)]; + Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg]; + + //Run the loop at least once to allow a 0 step tight window + do + { + //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window + DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep)); + + //calculate the time step + long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds); + + //try to compute the hash, must always be storable in the buffer + bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep); + Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough"); + + //If platform is little endian, reverse the byte order + if (BitConverter.IsLittleEndian) + { + stepBuffer.Reverse(); + } + + ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg); + + if (result < 1) + { + throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small"); + } + + codeMatches |= totpCode == CalcTOTPCode(config.TOTPDigits, hashBuffer[..(int)result]); + + currenStep++; + + } while (currenStep <= config.TOTPTimeWindowSteps); + + return codeMatches; + } + + private static uint CalcTOTPCode(int digits, ReadOnlySpan<byte> hash) + { + //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output + byte offset = (byte)(hash[^1] & 0x0Fu); + + uint TOTPCode; + if (BitConverter.IsLittleEndian) + { + //Store the code components + TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu; + } + else + { + //Store the code components (In reverse order for big-endian machines) + TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu; + } + //calculate the modulus value + TOTPCode %= (uint)Math.Pow(10, digits); + return TOTPCode; + } + + public void ExtendUpgradePayload(in JwtPayload message, IUser user) + { } + } +}
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs new file mode 100644 index 0000000..c500a7e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs @@ -0,0 +1,84 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: UserTotpMfaExtensions.cs +* +* UserTotpMfaExtensions.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 VNLib.Hashing; +using VNLib.Utils; +using VNLib.Plugins.Essentials.Users; + +namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp +{ + public static class UserTotpMfaExtensions + { + public const string TOTP_KEY_ENTRY = "mfa.totp"; + + /// <summary> + /// Recovers the base32 encoded TOTP secret for the current user + /// </summary> + /// <param name="user"></param> + /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns> + public static string TotpGetSecret(this IUser user) => user[TOTP_KEY_ENTRY]; + + /// <summary> + /// Stores or removes the current user's TOTP secret, stored in base32 format + /// </summary> + /// <param name="user"></param> + /// <param name="secret">The base32 encoded TOTP secret</param> + public static void TotpSetSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!; + + /// <summary> + /// Determines if the user account has TOTP enabled + /// </summary> + /// <param name="user"></param> + /// <returns>True if the user has totp enabled, false otherwise</returns> + public static bool TotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]); + + /// <summary> + /// Disables TOTP for the current user + /// </summary> + /// <param name="user"></param> + public static void TotpDisable(this IUser user) => user[TOTP_KEY_ENTRY] = null!; + + /// <summary> + /// Generates/overwrites the current user's TOTP secret entry and returns a + /// byte array of the generated secret bytes + /// </summary> + /// <param name="config">The system MFA configuration</param> + /// <returns>The raw secret that was encrypted and stored in the user's object</returns> + /// <exception cref="OutOfMemoryException"></exception> + 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 + byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes); + //Store secret in user storage + user.TotpSetSecret(VnEncoding.ToBase32String(newSecret, false)); + //return the raw secret bytes + return newSecret; + } + + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs new file mode 100644 index 0000000..8711fb9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs @@ -0,0 +1,99 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: MfaEncodedData.cs +* +* MfaEncodedData.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.Text.Json; + +using VNLib.Utils; +using VNLib.Utils.IO; +using VNLib.Utils.Memory; + + +namespace VNLib.Plugins.Essentials.Accounts.MFA +{ + internal static class UserEnocdedData + { + /// <summary> + /// Recovers encoded items from the user's account object + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="store">The data store to read encoded data from</param> + /// <param name="index">The property index in the user fields to recover the objects from</param> + /// <returns>The encoded properties from the desired user index</returns> + public static T? Decode<T>(IIndexable<string, string> store, string index) where T : class + { + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(index); + + string? encodedData = store[index]; + + if (string.IsNullOrWhiteSpace(encodedData)) + { + return null; + } + + //Output buffer will always be smaller than actual input data due to base64 encoding + using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(encodedData.Length, true); + + ERRNO bytes = VnEncoding.Base64UrlDecode(encodedData, binBuffer.Span); + + if (!bytes) + { + return null; + } + + //Deserialize the objects directly from binary data + return JsonSerializer.Deserialize<T>( + utf8Json: binBuffer.AsSpan(0, bytes), + options: Statics.SR_OPTIONS + ); + } + + /// <summary> + /// Writes a set of items to the user's account object, encoded in base64 + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="store"></param> + /// <param name="index">The store index to write the encoded string data to</param> + /// <param name="instance">The object instance to encode and store</param> + public static void Encode<T>(IIndexable<string, string> store, string index, T? instance) where T : class + { + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(index); + + if (instance == null) + { + store[index] = null!; + return; + } + + //Use a memory stream to serialize the items safely + using VnMemoryStream ms = new(MemoryUtil.Shared, 1024, false); + + JsonSerializer.Serialize(ms, instance, Statics.SR_OPTIONS); + + store[index] = VnEncoding.ToBase64UrlSafeString(ms.AsSpan(), false); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs deleted file mode 100644 index 9bf04c8..0000000 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ /dev/null @@ -1,490 +0,0 @@ -/* -* Copyright (c) 2023 Vaughn Nugent -* -* Library: VNLib -* Package: VNLib.Plugins.Essentials.Accounts -* File: UserMFAExtensions.cs -* -* UserMFAExtensions.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.Linq; -using System.Buffers; -using System.Text.Json; -using System.Diagnostics; -using System.Collections.Generic; - -using VNLib.Hashing; -using VNLib.Utils; -using VNLib.Utils.Memory; -using VNLib.Utils.Extensions; -using VNLib.Hashing.IdentityUtility; -using VNLib.Plugins.Essentials.Users; -using VNLib.Plugins.Essentials.Sessions; - -namespace VNLib.Plugins.Essentials.Accounts.MFA -{ - - 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"; - - /// <summary> - /// Determines if the user account has an - /// </summary> - /// <param name="user"></param> - /// <returns>True if any form of MFA is enabled for the user account</returns> - public static bool MFAEnabled(this IUser user) - { - return !(string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]) && string.IsNullOrWhiteSpace(user[WEBAUTHN_KEY_ENTRY])); - } - - /// <summary> - /// Disables all forms of MFA for the current user - /// </summary> - /// <param name="user"></param> - public static void MFADisable(this IUser user) - { - user[TOTP_KEY_ENTRY] = null!; - user[WEBAUTHN_KEY_ENTRY] = null!; - } - - #region totp - - /// <summary> - /// Recovers the base32 encoded TOTP secret for the current user - /// </summary> - /// <param name="user"></param> - /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns> - public static string MFAGetTOTPSecret(this IUser user) => user[TOTP_KEY_ENTRY]; - - /// <summary> - /// Stores or removes the current user's TOTP secret, stored in base32 format - /// </summary> - /// <param name="user"></param> - /// <param name="secret">The base32 encoded TOTP secret</param> - public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!; - - /// <summary> - /// Determines if the user account has TOTP enabled - /// </summary> - /// <param name="user"></param> - /// <returns>True if the user has totp enabled, false otherwise</returns> - public static bool MFATotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]); - - /// <summary> - /// Generates/overwrites the current user's TOTP secret entry and returns a - /// byte array of the generated secret bytes - /// </summary> - /// <param name="entry">The <see cref="MFAEntry"/> to modify the TOTP configuration of</param> - /// <returns>The raw secret that was encrypted and stored in the <see cref="MFAEntry"/>, to send to the client</returns> - /// <exception cref="OutOfMemoryException"></exception> - 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 - byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes); - //Store secret in user storage - user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false)); - //return the raw secret bytes - return newSecret; - } - - /// <summary> - /// Verfies the supplied TOTP code against the current user's totp codes - /// This method should not be used for verifying TOTP codes for authentication - /// </summary> - /// <param name="user">The user account to verify the TOTP code against</param> - /// <param name="code">The code to verify</param> - /// <param name="config">A readonly referrence to the MFA configuration structure</param> - /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns> - /// <exception cref="FormatException"></exception> - /// <exception cref="OutOfMemoryException"></exception> - 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(); - if (!config.TOTPEnabled || string.IsNullOrWhiteSpace(base32Secret)) - { - return false; - } - - int length = base32Secret.Length; - bool isValid; - - if (length > 256) - { - //Alloc buffer with zero o - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true); - - ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span); - //Verify the TOTP using the decrypted secret - isValid = count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig); - //Zero out the buffer - MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.IntLength); - } - else - { - //stack alloc buffer - Span<byte> 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; - } - - private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, TOTPConfig config) - { - //A basic attempt at a constant time TOTP verification, run the calculation a fixed number of times, regardless of the resutls - bool codeMatches = false; - - //cache current time - DateTimeOffset currentUtc = DateTimeOffset.UtcNow; - //Start the current window with the minimum window - int currenStep = -config.TOTPTimeWindowSteps; - Span<byte> stepBuffer = stackalloc byte[sizeof(long)]; - Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg]; - //Run the loop at least once to allow a 0 step tight window - do - { - //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window - DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep)); - //calculate the time step - long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds); - //try to compute the hash, must always be storable in the buffer - bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep); - Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough"); - //If platform is little endian, reverse the byte order - if (BitConverter.IsLittleEndian) - { - stepBuffer.Reverse(); - } - ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg); - //try to compute the hash of the time step - if (result < 1) - { - throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small"); - } - //Hash bytes - ReadOnlySpan<byte> hash = hashBuffer[..(int)result]; - //compute the TOTP code and compare it to the supplied, then store the result - codeMatches |= (totpCode == CalcTOTPCode(hash, config)); - //next step - currenStep++; - } while (currenStep <= config.TOTPTimeWindowSteps); - - return codeMatches; - } - - private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, TOTPConfig config) - { - //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output - byte offset = (byte)(hash[^1] & 0x0Fu); - - uint TOTPCode; - if (BitConverter.IsLittleEndian) - { - //Store the code components - TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu; - } - else - { - //Store the code components (In reverse order for big-endian machines) - TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu; - } - //calculate the modulus value - TOTPCode %= (uint)Math.Pow(10, config.TOTPDigits); - return TOTPCode; - } - - #endregion - - #region PKI - - /// <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) - { - /* - * 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)); - - if(pub == null) - { - return false; - } - - //verify the jwt - return jwt.VerifyFromJwk(pub); - } - - /// <summary> - /// Stores an array of public keys in the user's account object - /// </summary> - /// <param name="user"></param> - /// <param name="authKeys">The array of jwk format keys to store for the user</param> - public static void PKISetPublicKeys(this IUser user, PkiAuthPublicKey[]? authKeys) - { - if(authKeys == null || authKeys.Length == 0) - { - user[USER_PKI_ENTRY] = null!; - return; - } - - //Serialize the key data - byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(authKeys, Statics.SR_OPTIONS); - - //convert to base64 string before writing user data - user[USER_PKI_ENTRY] = VnEncoding.ToBase64UrlSafeString(keyData, false); - } - - /// <summary> - /// Gets all public keys stored in the user's account object - /// </summary> - /// <param name="user"></param> - /// <returns>The array of public keys if the exist</returns> - public static PkiAuthPublicKey[]? PkiGetAllPublicKeys(this IUser user) - { - string? keyData = user[USER_PKI_ENTRY]; - - if(string.IsNullOrEmpty(keyData)) - { - return null; - } - - //Alloc bin buffer for base64 conversion - using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(keyData.Length, true); - - //Recover base64 bytes from key data - ERRNO bytes = VnEncoding.Base64UrlDecode(keyData, binBuffer.Span); - if (!bytes) - { - return null; - } - - //Deserialize the the key array - return JsonSerializer.Deserialize<PkiAuthPublicKey[]>(binBuffer.AsSpan(0, bytes), Statics.SR_OPTIONS); - } - - /// <summary> - /// Removes a single pki key by it's id - /// </summary> - /// <param name="user"></param> - /// <param name="keyId">The id of the key to remove</param> - 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); - } - - /// <summary> - /// Adds a single pki key to the user's account object, or overwrites - /// and existing key with the same id - /// </summary> - /// <param name="user"></param> - /// <param name="key">The key to add to the list of user-keys</param> - public static void PKIAddPublicKey(this IUser user, PkiAuthPublicKey key) - { - //get all keys - PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user); - - if (keys == null) - { - keys = new PkiAuthPublicKey[] { key }; - } - else - { - //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 - - #region webauthn - - #endregion - - private static HashAlg SigingAlg { get; } = HashAlg.SHA256; - - private static ReadOnlyMemory<byte> UpgradeHeader { get; } = CompileJwtHeader(); - - private static byte[] CompileJwtHeader() - { - Dictionary<string, string> header = new() - { - { "alg","HS256" }, - { "typ", "JWT" } - }; - return JsonSerializer.SerializeToUtf8Bytes(header); - } - - /// <summary> - /// Recovers a signed MFA upgrade JWT and verifies its authenticity, and confrims its not expired, - /// then recovers the upgrade mssage - /// </summary> - /// <param name="config"></param> - /// <param name="upgradeJwtString">The signed JWT upgrade message</param> - /// <param name="base32Secret">The stored base64 encoded signature from the session that requested an upgrade</param> - /// <returns>True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise</returns> - internal static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret) - { - //Parse jwt - using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString); - - //Recover the secret key - byte[] secret = VnEncoding.FromBase32String(base32Secret)!; - try - { - //Verify the signature - if (!jwt.Verify(secret, SigingAlg)) - { - return null; - } - } - finally - { - //Erase secret - MemoryUtil.InitializeBlock(secret.AsSpan()); - } - //Valid - - //get request body - using JsonDocument doc = jwt.GetPayload(); - - //Recover issued at time - DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(doc.RootElement.GetProperty("iat").GetInt64()); - - //Verify its not timed out - if (iat.Add(config.UpgradeValidFor) < DateTimeOffset.UtcNow) - { - //expired - return null; - } - - //Recover the upgrade message - return doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>(); - } - - - /// <summary> - /// Generates an upgrade for the requested user, using the highest prirotiy method - /// </summary> - /// <param name="login">The message from the user requesting the login</param> - /// <returns>A signed upgrade message the client will pass back to the server after the MFA verification</returns> - /// <exception cref="InvalidOperationException"></exception> - internal static MfaUpgradeMessage? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login) - { - //Webauthn config - - - //Search for totp secret entry - string base32Secret = user.MFAGetTOTPSecret(); - - //Check totp entry - if (!string.IsNullOrWhiteSpace(base32Secret)) - { - - //setup the upgrade - MFAUpgrade upgrade = new() - { - //Set totp upgrade type - Type = MFAType.TOTP, - //Store login message details - UserName = login.UserName, - ClientId = login.ClientId, - PublicKey = login.ClientPublicKey, - ClientLocalLanguage = login.LocalLanguage, - }; - - //Init jwt for upgrade - return GetUpgradeMessage(upgrade, conf); - } - return null; - } - - private static MfaUpgradeMessage GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config) - { - //Add some random entropy to the upgrade message, to help prevent forgery - string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes); - //Init jwt - using JsonWebToken upgradeJwt = new(); - //Add header - upgradeJwt.WriteHeader(UpgradeHeader.Span); - //Write claims - upgradeJwt.InitPayloadClaim() - .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) - .AddClaim("upgrade", upgrade) - .AddClaim("type", upgrade.Type.ToString().ToLower(null)) - .AddClaim("expires", config.UpgradeValidFor.TotalSeconds) - .AddClaim("a", entropy) - .CommitClaims(); - - //Generate a new random secret - byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes); - - //sign jwt - upgradeJwt.Sign(secret, SigingAlg); - - //compile and return jwt upgrade - return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret)); - } - - internal static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!; - - internal static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY]; - } - - readonly record struct MfaUpgradeMessage(string ClientJwt, string SessionKey); -} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj index 8e37899..99867b5 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj @@ -50,6 +50,7 @@ <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> + <PackageReference Include="System.Formats.Cbor" Version="8.0.0" /> </ItemGroup> <ItemGroup> |