diff options
Diffstat (limited to 'plugins/VNLib.Plugins.Essentials.Accounts/src')
4 files changed, 107 insertions, 97 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs index 26a853a..5b50cb2 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs @@ -31,6 +31,7 @@ using System.Text.Json.Serialization; using FluentValidation; +using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; @@ -44,6 +45,17 @@ using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Loading.Users; using static VNLib.Plugins.Essentials.Statics; +/* + * Password only log-ins should be immune to repeat attacks on the same backend, because sessions are + * guarunteed to be mutally exclusive on the same system, therefor a successful login cannot be repeated + * without a logout with the proper authorization. + * + * Since MFA upgrades are indempodent upgrades can be regenerated continually as long as the session + * is not authorized, however login authorizations should be immune to repeats because session locking + * + * Session id's are also regenerated per request, the only possible vector could be stale session cache + * that has a valid MFA key and an old, but valid session id. + */ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { @@ -61,7 +73,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints private static readonly LoginMessageValidation LmValidator = new(); private readonly IPasswordHashingProvider Passwords; - private readonly MFAConfig? MultiFactor; + private readonly MFAConfig MultiFactor; private readonly IUserManager Users; private readonly uint MaxFailedLogins; private readonly TimeSpan FailedCountTimeout; @@ -79,16 +91,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints MultiFactor = pbase.GetConfigElement<MFAConfig>(); } - private class MfaUpgradeWebm : ValErrWebMessage + protected override ERRNO PreProccess(HttpEntity entity) { - [JsonPropertyName("pwtoken")] - public string? PasswordToken { get; set; } - - [JsonPropertyName("mfa")] - public bool? MultiFactorUpgrade { get; set; } = null; + //Cannot have new sessions + return base.PreProccess(entity) && !entity.Session.IsNew; } - protected async override ValueTask<VfReturnType> PostAsync(HttpEntity entity) { //Conflict if user is logged in @@ -99,7 +107,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //If mfa is enabled, allow processing via mfa - if (MultiFactor != null) + if (MultiFactor.FIDOEnabled || MultiFactor.TOTPEnabled) { if (entity.QueryArgs.ContainsKey("mfa")) { @@ -109,7 +117,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return await ProccesLoginAsync(entity); } - private async ValueTask<VfReturnType> ProccesLoginAsync(HttpEntity entity) { MfaUpgradeWebm webm = new(); @@ -173,13 +180,28 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints Log.Warn(uue); return VfReturnType.Error; } - } - + } + private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) { //Verify password before we tell the user the status of their account for security reasons - if (!Passwords.Verify(user.PassHash, new PrivateString(loginMessage.Password, false))) + if (!Passwords.Verify(user.PassHash, loginMessage.Password)) + { + return false; + } + + //Only allow active users + if (user.Status != UserStatus.Active) + { + //This is an unhandled case, and should never happen, but just incase write a warning to the log + Log.Warn("Account {uid} has invalid status key and a login was attempted from {ip}", user.UserID, entity.TrustedRemoteIp); + return false; + } + + //Is the account restricted to a local network connection? + if (user.LocalOnly && !entity.IsLocalConnection) { + Log.Information("User {uid} attempted a login from a non-local network with the correct password. Access was denied", user.UserID); return false; } @@ -188,34 +210,27 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints try { - if (user.Status == UserStatus.Active) - { - //Is the account restricted to a local network connection? - if (user.LocalOnly && !entity.IsLocalConnection) - { - Log.Information("User {uid} attempted a login from a non-local network with the correct password. Access was denied", user.UserID); - return false; - } + //get the new upgrade jwt string + MfaUpgradeMessage? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage); - //get the new upgrade jwt string - Tuple<string, string>? 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. + */ - //if message is null, mfa was not enabled or could not be prepared - if (message != null) - { - //Store the base64 signature - entity.Session.MfaUpgradeSecret(message.Item2); - - //send challenge message to client - webm.Result = message.Item1; - webm.Success = true; - webm.MultiFactorUpgrade = true; - - return true; - } + //if message is null, mfa was not enabled or could not be prepared + if (message.HasValue) + { + //Store the base64 signature + entity.Session.MfaUpgradeSecret(message.Value.SessionKey); - //Set password token - webm.PasswordToken = null; + //send challenge message to client + webm.Result = message.Value.ClientJwt; + webm.MultiFactorUpgrade = true; + } + else + { + /* SUCCESSFULL LOGIN! */ //Elevate the login status of the session to reflect the user's status entity.GenerateAuthorization(loginMessage, user, webm); @@ -226,18 +241,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints EmailAddress = user.EmailAddress, }; - webm.Success = true; //Write to log Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); - - return true; - } - else - { - //This is an unhandled case, and should never happen, but just incase write a warning to the log - Log.Warn("Account {uid} has invalid status key and a login was attempted from {ip}", user.UserID, entity.TrustedRemoteIp); - return false; } + + webm.Success = true; + return true; } /* * Account auhorization may throw excetpions if the configuration does not @@ -254,8 +263,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints Log.Debug(ce); } return false; - } - + } private async ValueTask<VfReturnType> ProcessMfaAsync(HttpEntity entity) { @@ -340,10 +348,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { //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 user.FailedLoginIncrement(entity.RequestedTimeUtc); return; @@ -361,30 +371,22 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return; } + //SUCCESSFUL LOGIN + //Wipe session signature entity.Session.MfaUpgradeSecret(null); - //build login message from upgrade - LoginMessage loginMessage = new() - { - ClientId = upgrade.ClientID, - ClientPublicKey = upgrade.Base64PubKey, - LocalLanguage = upgrade.ClientLocalLanguage, - LocalTime = localTime, - UserName = upgrade.UserName - }; - //Elevate the login status of the session to reflect the user's status - entity.GenerateAuthorization(loginMessage, user, webm); - - //Set the password token as the password field of the login message - webm.PasswordToken = upgrade.PwClientData; + 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]); } @@ -411,5 +413,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Count has been exceeded, and has not timed out yet return true; } + + private sealed class MfaUpgradeWebm : ValErrWebMessage + { + + [JsonPropertyName("mfa")] + public bool? MultiFactorUpgrade { get; set; } = null; + } } }
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs index e7c8a86..48a3345 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs @@ -54,6 +54,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { public const string INVALID_MESSAGE = "Your assertion is invalid, please regenerate and try again"; + /* + * I am only supporting EC keys for size reasons, user objects are limited in back-end size and keys can + * take up large ammounts of data. + */ private static readonly ImmutableArray<string> AllowedCurves = new string[3] { "P-256", "P-384", "P-521"}.ToImmutableArray(); private static readonly ImmutableArray<string> AllowedAlgs = new string[3] { "ES256", "ES384", "ES512" }.ToImmutableArray(); diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs index 5577d51..e69088a 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts @@ -24,17 +24,15 @@ using System.Text.Json.Serialization; -#nullable enable - namespace VNLib.Plugins.Essentials.Accounts.MFA { - internal class MFAUpgrade + internal class MFAUpgrade : IClientSecInfo { /// <summary> /// The login's client id specifier /// </summary> [JsonPropertyName("cid")] - public string? ClientID { get; set; } + public string? ClientId { get; set; } /// <summary> /// The id of the user that is requesting a login /// </summary> @@ -50,16 +48,11 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// public key /// </summary> [JsonPropertyName("pubkey")] - public string? Base64PubKey { get; set; } + public string? PublicKey { get; set; } /// <summary> /// The user's specified language /// </summary> [JsonPropertyName("lang")] public string? ClientLocalLanguage { get; set; } - /// <summary> - /// The encrypted password token for the client - /// </summary> - [JsonPropertyName("cd")] - public string? PwClientData { get; set; } } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs index 63b2a2b..99f7fbb 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs @@ -23,12 +23,10 @@ */ using System; -using System.Text; using System.Linq; using System.Buffers; using System.Text.Json; using System.Collections.Generic; -using System.Security.Cryptography; using System.Text.Json.Serialization; using VNLib.Hashing; @@ -229,10 +227,16 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA return jwt.VerifyFromJwk(jwk); } - public static void PKISetUserKey(this IUser user, IReadOnlyDictionary<string, string>? keyData) + public static void PKISetUserKey(this IUser user, IReadOnlyDictionary<string, string>? keyFields) { + //Serialize the key data + byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(keyFields, Statics.SR_OPTIONS); + + //convert to base32 string before writing user data + string base64 = Convert.ToBase64String(keyData); + //Store key data - user.SetObject(USER_PKI_ENTRY, keyData); + user[USER_PKI_ENTRY] = base64; } private static ReadOnlyJsonWebKey? RecoverKey(IUser user) @@ -248,9 +252,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA byte[] buffer = ArrayPool<byte>.Shared.Rent(JWK_KEY_BUFFER_SIZE); try { - //Recover bytes and get the jwk from the data - int encoded = Encoding.UTF8.GetBytes(keyData, buffer); - return new ReadOnlyJsonWebKey(buffer.AsSpan(0, encoded)); + //Recover base64 bytes from key data + ERRNO bytes = VnEncoding.TryFromBase64Chars(keyData, buffer); + if (!bytes) + { + return null; + } + //Recover json from the decoded binary data + return new ReadOnlyJsonWebKey(buffer.AsSpan(0, bytes)); } finally { @@ -293,7 +302,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA #endregion - private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key); + private static HashAlg SigingAlg { get; } = HashAlg.SHA256; private static ReadOnlyMemory<byte> UpgradeHeader { get; } = CompileJwtHeader(); @@ -313,7 +322,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// </summary> /// <param name="config"></param> /// <param name="upgradeJwtString">The signed JWT upgrade message</param> - /// <param name="upgrade">The recovered upgrade</param> /// <param name="base32Secret">The stored base64 encoded signature from the session that requested an upgrade</param> /// <returns>True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise</returns> public static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret) @@ -325,10 +333,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA byte[] secret = VnEncoding.FromBase32String(base32Secret)!; try { - //Verify the - using HMAC hmac = GetSigningAlg(secret); - - if (!jwt.Verify(hmac)) + //Verify the signature + if (!jwt.Verify(secret, SigingAlg)) { return null; } @@ -364,7 +370,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA /// <param name="login">The message from the user requesting the login</param> /// <returns>A signed upgrade message the client will pass back to the server after the MFA verification</returns> /// <exception cref="InvalidOperationException"></exception> - public static Tuple<string, string>? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login) + public static MfaUpgradeMessage? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login) { //Webauthn config @@ -383,8 +389,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA Type = MFAType.TOTP, //Store login message details UserName = login.UserName, - ClientID = login.ClientId, - Base64PubKey = login.ClientPublicKey, + ClientId = login.ClientId, + PublicKey = login.ClientPublicKey, ClientLocalLanguage = login.LocalLanguage, }; @@ -394,7 +400,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA return null; } - private static Tuple<string, string> GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config) + 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); @@ -414,12 +420,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA //Generate a new random secret byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes); - //Init alg - using(HMAC alg = GetSigningAlg(secret)) - { - //sign jwt - upgradeJwt.Sign(alg); - } + //sign jwt + upgradeJwt.Sign(secret, SigingAlg); //compile and return jwt upgrade return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret)); @@ -429,4 +431,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA public static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY]; } + + readonly record struct MfaUpgradeMessage(string ClientJwt, string SessionKey); } |