diff options
Diffstat (limited to 'plugins')
16 files changed, 634 insertions, 532 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs index b84728b..3292e6b 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.Accounts.Registration @@ -34,7 +34,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration /// <summary> /// Central password requirement validator /// </summary> - public static IValidator<string> PasswordValidator { get; } = GetPassVal(); + public static IValidator<string?> PasswordValidator { get; } = GetPassVal(); public static IValidator<AccountData> AccountDataValidator { get; } = GetAcVal(); @@ -43,9 +43,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration /// </summary> public static IValidator<RegRequestMessage> RegRequestValidator { get; } = GetRequestValidator(); - static IValidator<string> GetPassVal() + static IValidator<string?> GetPassVal() { - InlineValidator<string> passVal = new(); + InlineValidator<string?> passVal = new(); passVal.RuleFor(static password => password) .NotEmpty() diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs index 6a81e7e..2172760 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs @@ -25,6 +25,7 @@ using System; using System.Net; using System.Text.Json; +using System.Text.Json.Serialization; using FluentValidation; @@ -49,7 +50,6 @@ using VNLib.Plugins.Extensions.Validation; using VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation; using static VNLib.Plugins.Essentials.Accounts.AccountUtil; - namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints { @@ -63,10 +63,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints const string FAILED_AUTH_ERR = "Your registration does not exist, you should try to regisiter again."; const string REG_ERR_MESSAGE = "Please check your email inbox."; - + + private static readonly IValidator<RegCompletionRequest> RegCompletionValidator = RegCompletionRequest.GetValidator(); + private readonly IUserManager Users; - private readonly IValidator<string> RegJwtValdidator; - private readonly IPasswordHashingProvider Passwords; private readonly RevokedTokenStore RevokedTokens; private readonly TransactionalEmailConfig Emails; private readonly IAsyncLazy<ReadOnlyJsonWebKey> RegSignatureKey; @@ -84,11 +84,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints InitPathAndLog(path, plugin.Log); RegExpiresSec = config["reg_expires_sec"].GetTimeSpan(TimeParseType.Seconds); - - //Init reg jwt validator - RegJwtValdidator = GetJwtValidator(); - - Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>(); + Users = plugin.GetOrCreateSingleton<UserManager>(); Emails = plugin.GetOrCreateSingleton<TEmailConfig>(); RevokedTokens = new(plugin.GetContextOptions()); @@ -98,21 +94,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints .ToLazy(static sr => sr.GetJsonWebKey()); } - - private static IValidator<string> GetJwtValidator() - { - InlineValidator<string> val = new(); - - val.RuleFor(static s => s) - .NotEmpty() - //Must contain 2 periods for jwt limitation - .Must(static s => s.Count(s => s == '.') == 2) - //Guard length - .Length(20, 500) - .IllegalCharacters(); - return val; - } - //Schedule cleanup interval [AsyncInterval(Minutes = 5)] public async Task OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) @@ -125,66 +106,39 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); + //Get the json request data from client - using JsonDocument? request = await entity.GetJsonFromFileAsync(); + using RegCompletionRequest? request = await entity.GetJsonFromFileAsync<RegCompletionRequest>(); if(webm.Assert(request != null, "No request data present")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } - //Get the jwt string from client - string? regJwt = request.RootElement.GetPropString("token"); - using PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password"); - - //validate inputs + if(!RegCompletionValidator.Validate(request, webm)) { - if (webm.Assert(regJwt != null, FAILED_AUTH_ERR)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - if (webm.Assert(password != null, "You must specify a password.")) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - //validate new password - if(!AccountValidations.PasswordValidator.Validate((string)password, webm)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - //Validate jwt - if (webm.Assert(RegJwtValdidator.Validate(regJwt).IsValid, FAILED_AUTH_ERR)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } - //Verify jwt has not been revoked - if(await RevokedTokens.IsRevokedAsync(regJwt, entity.EventCancellation)) + //Verify jwt has not been revoked + bool isRevoked = await RevokedTokens.IsRevokedAsync(request.Token!, entity.EventCancellation); + if (webm.Assert(!isRevoked, FAILED_AUTH_ERR)) { - webm.Result = FAILED_AUTH_ERR; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } string emailAddress; try { //get jwt - using JsonWebToken jwt = JsonWebToken.Parse(regJwt); + using JsonWebToken jwt = JsonWebToken.Parse(request.Token); + //verify signature bool verified = jwt.VerifyFromJwk(RegSignatureKey.Value); if (webm.Assert(verified, FAILED_AUTH_ERR)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //recover iat and email address @@ -195,32 +149,28 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints //Verify IAT against expiration at second resolution if (webm.Assert(iat.Add(RegExpiresSec) > entity.RequestedTimeUtc, FAILED_AUTH_ERR)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } } catch (FormatException fe) { Log.Debug(fe); webm.Result = FAILED_AUTH_ERR; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } - - - //Always hash the new password, even if failed - using PrivateString passHash = Passwords.Hash(password); try { - //Generate userid from email - string uid = GetRandomUserId(); - - //Create the new user - using IUser user = await Users.CreateUserAsync(uid, emailAddress, MINIMUM_LEVEL, passHash, entity.EventCancellation); + UserCreationRequest creation = new() + { + EmailAddress = emailAddress, + InitialStatus = UserStatus.Active, + Password = request.GetPassPrivString(), + }; - //Set active status - user.Status = UserStatus.Active; + //Create the new user with random user-id + using IUser user = await Users.CreateUserAsync(creation, null, entity.EventCancellation); + //set local account origin user.SetAccountOrigin(LOCAL_ACCOUNT_ORIGIN); @@ -228,12 +178,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints await user.ReleaseAsync(); //Revoke token now complete - _ = RevokedTokens.RevokeAsync(regJwt, CancellationToken.None).ConfigureAwait(false); + _ = RevokedTokens.RevokeAsync(request.Token, CancellationToken.None).ConfigureAwait(false); webm.Result = "Successfully created your new account. You may now log in"; webm.Success = true; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + + return VirtualOk(entity, webm); } //Capture creation failed, this may be a replay catch (UserExistsException) @@ -244,8 +194,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints } webm.Result = FAILED_AUTH_ERR; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) @@ -366,6 +315,43 @@ namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints Log.Error(ex); } } - + + + private sealed class RegCompletionRequest : PrivateStringManager + { + public RegCompletionRequest() : base(1) + { } + + [JsonPropertyName("password")] + public string? Password + { + get => this[0]; + set => this[0] = value; + } + + [JsonPropertyName("token")] + public string? Token { get; set; } + + public PrivateString? GetPassPrivString() => PrivateString.ToPrivateString(this[0], false); + + public static IValidator<RegCompletionRequest> GetValidator() + { + InlineValidator<RegCompletionRequest> validator = new(); + + validator.RuleFor(x => x.Password) + .NotEmpty() + .SetValidator(AccountValidations.PasswordValidator); + + validator.RuleFor(x => x.Token) + .NotEmpty() + //Must contain 2 periods for jwt limitation + .Must(static s => s!.Count(static s => s == '.') == 2) + //Guard length + .Length(20, 500) + .IllegalCharacters(); + + return validator; + } + } } }
\ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index 5f171cd..d524902 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -29,7 +29,6 @@ using System.ComponentModel.Design; using FluentValidation.Results; using VNLib.Utils; -using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Plugins.Attributes; using VNLib.Plugins.Essentials.Users; @@ -40,9 +39,11 @@ 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.Utils.Memory; namespace VNLib.Plugins.Essentials.Accounts { + public sealed class AccountsEntryPoint : PluginBase { @@ -139,7 +140,6 @@ namespace VNLib.Plugins.Essentials.Accounts ArgumentList args = new(cmd.Split(' ')); IUserManager Users = this.GetOrCreateSingleton<UserManager>(); - IPasswordHashingProvider Passwords = this.GetOrCreateSingleton<ManagedPasswordHashing>(); string? username = args.GetArgument("-u"); string? password = args.GetArgument("-p"); @@ -187,13 +187,18 @@ Commands: privLevel = AccountUtil.MINIMUM_LEVEL; } - //Hash the password - using PrivateString passHash = Passwords.Hash(password); + //Create the user creation request + UserCreationRequest creation = new() + { + EmailAddress = username, + InitialStatus = UserStatus.Active, + Privileges = privLevel, + Password = PrivateString.ToPrivateString(password, false) + }; + //Create the user - using IUser user = await Users.CreateUserAsync(username, passHash, privLevel); - - //Set active flag - user.Status = UserStatus.Active; + using IUser user = await Users.CreateUserAsync(creation, null); + //Set local account user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN); @@ -210,9 +215,6 @@ Commands: break; } - //Hash the password - using PrivateString passHash = Passwords.Hash(password); - //Get the user using IUser? user = await Users.GetUserFromEmailAsync(username); @@ -223,7 +225,7 @@ Commands: } //Set the password - await Users.UpdatePassAsync(user, passHash); + await Users.UpdatePasswordAsync(user, password); Log.Information("Successfully reset password for {id}", username); } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs index 66a099e..2475f36 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs @@ -25,6 +25,7 @@ using System; using System.Net; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using System.Security.Cryptography; using System.Text.Json.Serialization; @@ -32,7 +33,6 @@ using System.Text.Json.Serialization; using FluentValidation; using VNLib.Utils; -using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; @@ -45,6 +45,7 @@ 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 @@ -71,8 +72,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints public const string MFA_ERROR_MESSAGE = "Invalid or expired request."; private static readonly LoginMessageValidation LmValidator = new(); - - private readonly IPasswordHashingProvider Passwords; + private readonly MFAConfig MultiFactor; private readonly IUserManager Users; private readonly FailedLoginLockout _lockout; @@ -84,8 +84,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints uint maxLogins = config["max_login_attempts"].GetUInt32(); InitPathAndLog(path, pbase.Log); - - Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>(); + Users = pbase.GetOrCreateSingleton<UserManager>(); MultiFactor = pbase.GetConfigElement<MFAConfig>(); _lockout = new(maxLogins, duration); @@ -136,9 +135,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } - - //Time to get the user - using IUser? user = await Users.GetUserAndPassFromEmailAsync(loginMessage.UserName); + + using IUser? user = await Users.GetUserFromEmailAsync(loginMessage.UserName); //Make sure account exists if (webm.Assert(user != null, INVALID_MESSAGE)) @@ -155,15 +153,25 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Only allow local accounts - if (user.IsLocalAccount() && !PrivateString.IsNullOrEmpty(user.PassHash)) + if (!user.IsLocalAccount()) { - //If login return true, the response has been set and we should return - if (LoginUser(entity, loginMessage, user, webm)) - { - goto Cleanup; - } + goto Failed; + } + + //Validate password + if (await ValidatePasswordAsync(user, loginMessage, entity.EventCancellation) == false) + { + goto Failed; + } + + //If login return true, the response has been set and we should return + if (LoginUser(entity, loginMessage, user, webm)) + { + goto Cleanup; } + Failed: + //Inc failed login count _lockout.Increment(user, entity.RequestedTimeUtc); webm.Result = INVALID_MESSAGE; @@ -177,16 +185,19 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints Log.Warn(uue); return VfReturnType.Error; } - } + } + + private async Task<bool> ValidatePasswordAsync(IUser user, LoginMessage login, CancellationToken cancellation) + { + //Validate password against store + ERRNO valResult = await Users.ValidatePasswordAsync(user, login.Password!, PassValidateFlags.None, cancellation); + + //Valid results are greater than 0; + return valResult > 0; + } private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm) { - //Verify password before we tell the user the status of their account for security reasons - if (!Passwords.Verify(user.PassHash!, loginMessage.Password)) - { - return false; - } - //Only allow active users if (user.Status != UserStatus.Active) { @@ -195,13 +206,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints 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; - } - //Reset flc for account, either the user will be authorized, or the mfa will be triggered, but the flc should be reset user.ClearFailedLoginCount(); diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs index d9cfd49..a156ccc 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs @@ -47,10 +47,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints internal sealed class MFAEndpoint : ProtectedWebEndpoint { public const int TOTP_URL_MAX_CHARS = 1024; + private const string CHECK_PASSWORD = "Please check your password"; private readonly IUserManager Users; private readonly MFAConfig? MultiFactor; - private readonly IPasswordHashingProvider Passwords; public MFAEndpoint(PluginBase pbase, IConfigScope config) { @@ -59,7 +59,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints Users = pbase.GetOrCreateSingleton<UserManager>(); MultiFactor = pbase.GetConfigElement<MFAConfig>(); - Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>(); } protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) @@ -124,7 +123,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //Get the user entry - using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID); + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); if (webm.Assert(user != null, "Please log-out and try again.")) { @@ -134,16 +133,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //get the user's password challenge using (PrivateString? password = (PrivateString?)mfaRequest.RootElement.GetPropString("password")) { - if (PrivateString.IsNullOrEmpty(password)) + if (webm.Assert(!PrivateString.IsNullOrEmpty(password), CHECK_PASSWORD)) { - webm.Result = "Please check your password"; return VirtualClose(entity, webm, HttpStatusCode.Unauthorized); } //Verify password against the user - if (!user.VerifyPassword(password, Passwords)) + ERRNO result = await Users.ValidatePasswordAsync(user, password, PassValidateFlags.None, entity.EventCancellation); + + if (webm.Assert(result > 0, CHECK_PASSWORD)) { - webm.Result = "Please check your password"; return VirtualClose(entity, webm, HttpStatusCode.Unauthorized); } } @@ -192,7 +191,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints string? mfaType = request.RootElement.GetProperty("type").GetString(); //get the user - using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID); + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID); if (user == null) { return VfReturnType.NotFound; @@ -204,16 +203,16 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints */ using (PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password")) { - if (PrivateString.IsNullOrEmpty(password)) + if (webm.Assert(!PrivateString.IsNullOrEmpty(password), CHECK_PASSWORD)) { - webm.Result = "Please check your password"; return VirtualClose(entity, webm, HttpStatusCode.Unauthorized); } //Verify password against the user - if (!user.VerifyPassword(password, Passwords)) + ERRNO result = await Users.ValidatePasswordAsync(user, password, PassValidateFlags.None, entity.EventCancellation); + + if (webm.Assert(result > 0, CHECK_PASSWORD)) { - webm.Result = "Please check your password"; return VirtualClose(entity, webm, HttpStatusCode.Unauthorized); } } @@ -221,8 +220,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //Check for totp disable if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase)) { - //Clear the TOTP secret + //Clear the TOTP secret to disable it user.MFASetTOTPSecret(null); + //write changes await user.ReleaseAsync(); webm.Result = "Successfully disabled your TOTP authentication"; diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs index 6f8cb77..33c72a7 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs @@ -29,16 +29,15 @@ using System.Text.Json.Serialization; using FluentValidation; +using VNLib.Utils; using VNLib.Utils.Memory; -using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Endpoints; +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.Endpoints; -using VNLib.Plugins.Essentials.Accounts.MFA; - namespace VNLib.Plugins.Essentials.Accounts.Endpoints { @@ -61,7 +60,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints internal sealed class PasswordChangeEndpoint : ProtectedWebEndpoint { private readonly IUserManager Users; - private readonly IPasswordHashingProvider Passwords; private readonly MFAConfig? mFAConfig; private readonly IValidator<PasswordResetMesage> ResetMessValidator; @@ -71,7 +69,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints InitPathAndLog(path, pbase.Log); Users = pbase.GetOrCreateSingleton<UserManager>(); - Passwords = pbase.GetOrCreateSingleton<ManagedPasswordHashing>(); ResetMessValidator = GetMessageValidator(); mFAConfig = pbase.GetConfigElement<MFAConfig>(); } @@ -95,10 +92,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return rules; } - /* - * If mfa config - */ - protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); @@ -118,7 +111,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } //get the user's entry in the table - using IUser? user = await Users.GetUserAndPassFromIDAsync(entity.Session.UserID); + using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation); if(webm.Assert(user != null, "An error has occured, please log-out and try again")) { @@ -131,10 +124,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints return VirtualOk(entity, webm); } + //Validate the user's current password + ERRNO isPassValid = await Users.ValidatePasswordAsync(user, pwReset.Current!, PassValidateFlags.None, entity.EventCancellation); + //Verify the user's old password - if (!Passwords.Verify(user.PassHash, pwReset.Current.AsSpan())) + if (webm.Assert(isPassValid > 0, "Please check your current password")) { - webm.Result = "Please check your current password"; return VirtualOk(entity, webm); } @@ -160,11 +155,8 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints //continue } - //Hash the user's new password - using PrivateString newPassHash = Passwords.Hash(pwReset.NewPassword.AsSpan()); - //Update the user's password - if (!await Users.UpdatePassAsync(user, newPassHash)) + if (!await Users.UpdatePasswordAsync(user, pwReset.NewPassword!, entity.EventCancellation)) { //error webm.Result = "Your password could not be updated"; diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs index 0abe657..4f4c830 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs @@ -204,6 +204,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints EmailAddress = user.EmailAddress, }; + //Write to log + Log.Verbose("Successful login for user {uid}...", user.UserID[..8]); + //Close response, user is now logged-in return VirtualOk(entity, webm); } @@ -422,8 +425,10 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints { [JsonPropertyName("pubkey")] public string? PublicKey { get; set; } + [JsonPropertyName("clientid")] public string? ClientId { get; set; } + [JsonPropertyName("login")] public string? LoginJwt { get; set; } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index d9c1703..0d1a714 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -757,7 +757,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider Domain = Config.CookieDomain, Path = Config.CookiePath, ValidFor = TimeSpan.Zero, - SameSite = CookieSameSite.SameSite, + SameSite = CookieSameSite.Strict, HttpOnly = true, Secure = entity.IsSecure }; @@ -779,7 +779,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider Domain = Config.CookieDomain, Path = Config.CookiePath, ValidFor = Config.AuthorizationValidFor, - SameSite = CookieSameSite.SameSite, + SameSite = CookieSameSite.Strict, HttpOnly = httpOnly, Secure = entity.IsSecure }; diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs index 18f4081..6a86cef 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientAccessTokenState.cs @@ -27,7 +27,7 @@ using System.Text.Json.Serialization; namespace VNLib.Plugins.Essentials.SocialOauth { - public sealed class OAuthAccessState : IOAuthAccessState + public class OAuthAccessState : IOAuthAccessState { ///<inheritdoc/> [JsonPropertyName("access_token")] diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs new file mode 100644 index 0000000..1e5a82e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/ClientClaimManager.cs @@ -0,0 +1,126 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.SocialOauth +* File: ClientClaimManager.cs +* +* ClientClaimManager.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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.Diagnostics.CodeAnalysis; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + internal sealed record class ClientClaimManager(ICookieController Cookies) + { + const string SESSION_SIG_KEY_NAME = "soa.sig"; + const int SIGNING_KEY_SIZE = 32; + + public bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) + { + claim = null; + + string? cookieValue = Cookies.GetCookie(entity); + + //Try to get the cookie + if (cookieValue == null) + { + return false; + } + + //Recover the signing key from the user's session + string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; + Span<byte> key = stackalloc byte[SIGNING_KEY_SIZE + 16]; + + ERRNO keySize = VnEncoding.Base64UrlDecode(sigKey, key); + + if (keySize < 1) + { + return false; + } + + try + { + //Try to parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); + + //Verify the jwt + if (!jwt.Verify(key[..(int)keySize], HashAlg.SHA256)) + { + return false; + } + + //Recover the clam from the jwt + claim = jwt.GetPayload<LoginClaim>(); + + //Verify the expiration time + return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); + } + catch (FormatException) + { + //JWT was corrupted and could not be parsed + return false; + } + finally + { + MemoryUtil.InitializeBlock(key); + } + } + + public void ClearClaimData(HttpEntity entity) + { + //Remove the upgrade cookie + Cookies.ExpireCookie(entity, false); + + //Clear the signing key from the session + entity.Session[SESSION_SIG_KEY_NAME] = null!; + } + + public void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + { + //Setup Jwt + using JsonWebToken jwt = new(); + + //Write claim body, we dont need a header + jwt.WritePayload(claim, Statics.SR_OPTIONS); + + //Generate signing key + byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); + + //Sign the jwt + jwt.Sign(sigKey, HashAlg.SHA256); + + Cookies.SetCookie(entity, jwt.Compile()); + + //Encode and store the signing key in the clien't session + entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase64UrlSafeString(sigKey, false); + + //Clear the signing key + MemoryUtil.InitializeBlock(sigKey.AsSpan()); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs index 2136d8a..f64d1c4 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs @@ -56,10 +56,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints * Creates a user-id from the users discord username, that is repeatable * and matches the Auth0 social user-id format */ - private static string GetUserIdFromPlatform(string userName) - { - return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } + private static string GetUserIdFromPlatform(string userName) => $"discord|{userName}"; ///<inheritdoc/> diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs index 1fd691b..e8abf5a 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs @@ -65,19 +65,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints .WithEndpoint<GetEmailRequest>() .WithMethod(Method.Get) .WithUrl(UserEmailUrl) - .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}") - .WithHeader("Accept", GITHUB_V3_ACCEPT); + .WithHeader("Accept", GITHUB_V3_ACCEPT) + .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}"); } - + /* * Creates a repeatable, and source specific user id for * GitHub users. This format is identical to the algorithim used * in the Auth0 Github connection, so it is compatible with Auth0 */ - private static string GetUserIdFromPlatform(int userId) - { - return ManagedHash.ComputeHash($"github|{userId}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } + private static string GetUserIdFromPlatform(int userId) => $"github|{userId}"; protected override async Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs new file mode 100644 index 0000000..fa425cc --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginClaim.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.SocialOauth +* File: LoginClaim.cs +* +* LoginClaim.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + +using VNLib.Hashing; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + internal sealed class LoginClaim : IClientSecInfo + { + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } + + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + [JsonPropertyName("locallanguage")] + public string? LocalLanguage { get; set; } + + [JsonPropertyName("pubkey")] + public string? PublicKey { get; set; } + + [JsonPropertyName("clientid")] + public string? ClientId { get; set; } + + + public void ComputeNonce(int nonceSize) + { + //Alloc nonce buffer + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize); + try + { + //fill the buffer with random data + RandomHash.GetRandomBytes(buffer.Span); + + //Base32-Encode nonce and save it + Nonce = VnEncoding.ToBase64UrlSafeString(buffer.Span, false); + } + finally + { + MemoryUtil.InitializeBlock(buffer.Span); + } + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs new file mode 100644 index 0000000..95334c6 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/LoginUriBuilder.cs @@ -0,0 +1,127 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.SocialOauth +* File: LoginUriBuilder.cs +* +* LoginUriBuilder.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.SocialOauth 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.SocialOauth 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; +using System.Runtime.InteropServices; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + /* + * Construct the client's redirect url based on their login claim, which contains + * a public key which can be used to encrypt the url so that only the client + * private-key holder can decrypt the url and redirect themselves to the + * target OAuth website. + * + * The result is an encrypted nonce that should guard against replay attacks and MITM + */ + + internal sealed record class LoginUriBuilder(OauthClientConfig Config) + { + private string? redirectUrl; + private string? nonce; + private Encoding _encoding = Encoding.UTF8; + + public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) + { + //Alloc stack buffer for url + Span<char> buffer = stackalloc char[1024]; + + //buffer writer for easier syntax + ForwardOnlyWriter<char> writer = new(buffer); + //first build the redirect url to re-encode it + writer.Append(scheme); + writer.Append("://"); + //Create redirect url (current page, default action is to authorize the client) + writer.Append(authority); + writer.Append(path); + //url encode the redirect path and save it for later + redirectUrl = Uri.EscapeDataString(writer.ToString()); + + return this; + } + + public LoginUriBuilder WithEncoding(Encoding encoding) + { + _encoding = encoding; + return this; + } + + public LoginUriBuilder WithNonce(string base32Nonce) + { + nonce = base32Nonce; + return this; + } + + public string Encrypt(HttpEntity client, IClientSecInfo secInfo) + { + //Alloc buffer and split it into binary and char buffers + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000); + + Span<byte> binBuffer = buffer.Span[2048..]; + Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]); + + + /* + * Build the character uri so we can encode it to binary, + * encrypt it and return it to the client + */ + + ForwardOnlyWriter<char> writer = new(charBuffer); + + //Append the config redirect path + writer.Append(Config.AccessCodeUrl.OriginalString); + //begin query arguments + writer.Append("&client_id="); + writer.Append(Config.ClientID.Value); + //add the redirect url + writer.Append("&redirect_uri="); + writer.Append(redirectUrl); + //Append the state parameter + writer.Append("&state="); + writer.Append(nonce); + + //Collect the written character data + ReadOnlySpan<char> url = writer.AsSpan(); + + //Separate bin buffers for encryption and encoding + Span<byte> encryptionBuffer = binBuffer[1024..]; + Span<byte> encodingBuffer = binBuffer[..1024]; + + //Encode the url to binary + int byteCount = _encoding.GetBytes(url, encodingBuffer); + + //Encrypt the binary data + ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); + + //base64 encode the encrypted + return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs index d053fc8..561962a 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs @@ -24,24 +24,18 @@ using System; using System.Net; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Security.Cryptography; using System.Text.Json.Serialization; -using System.Runtime.InteropServices; -using System.Diagnostics.CodeAnalysis; using FluentValidation; using RestSharp; using VNLib.Net.Http; -using VNLib.Hashing; -using VNLib.Hashing.IdentityUtility; using VNLib.Utils; -using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; using VNLib.Net.Rest.Client.Construction; @@ -67,10 +61,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; const string AUTH_GRANT_SESSION_NAME = "auth"; - const string SESSION_SIG_KEY_NAME = "soa.sig"; const string SESSION_TOKEN_KEY_NAME = "soa.tkn"; const string CLAIM_COOKIE_NAME = "extern-claim"; - const int SIGNING_KEY_SIZE = 32; + /// <summary> /// The client configuration struct passed during base class construction @@ -78,7 +71,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth protected virtual OauthClientConfig Config { get; } ///<inheritdoc/> - protected override ProtectionSettings EndpointProtectionSettings { get; } = new(); + protected override ProtectionSettings EndpointProtectionSettings { get; } /// <summary> /// The site adapter used to make requests to the OAuth2 provider @@ -90,14 +83,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// </summary> protected IUserManager Users { get; } - /// <summary> - /// The password hashing provider used to hash user passwords - /// </summary> - protected IPasswordHashingProvider Passwords { get; } - private readonly IValidator<LoginClaim> ClaimValidator; private readonly IValidator<string> NonceValidator; private readonly IValidator<AccountData> AccountDataValidator; + private readonly ClientClaimManager _claims; protected SocialOauthBase(PluginBase plugin, IConfigScope config) { @@ -112,7 +101,18 @@ namespace VNLib.Plugins.Essentials.SocialOauth InitPathAndLog(Config.EndpointPath, plugin.Log); Users = plugin.GetOrCreateSingleton<UserManager>(); - Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>(); + + + //Setup cookie controller and claim manager + SingleCookieController cookies = new(CLAIM_COOKIE_NAME, Config.InitClaimValidFor) + { + Secure = true, + HttpOnly = true, + SameSite = CookieSameSite.None, + Path = Path + }; + + _claims = new(cookies); //Define the site adapter SiteAdapter = new(); @@ -128,7 +128,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth .WithParameter("grant_type", "authorization_code") .WithParameter("code", r => r.Code) .WithParameter("redirect_uri", r => r.RedirectUrl); - } private static IValidator<LoginClaim> GetClaimValidator() @@ -197,9 +196,15 @@ namespace VNLib.Plugins.Essentials.SocialOauth { //Create new request object GetTokenRequest req = new(code, $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}"); - + //Execute request and attempt to recover the authorization response - OAuthAccessState? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<OAuthAccessState>(); + Oauth2TokenResult? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<Oauth2TokenResult>(); + + if(response?.Error != null) + { + Log.Debug("Error result from {conf} code {code} description: {err}", Config.AccountOrigin, response.Error, response.ErrorDescription); + return null; + } return response; } @@ -262,17 +267,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Build the redirect uri webm.Result = new LoginUriBuilder(Config) .WithEncoding(entity.Server.Encoding) - .WithUrl(entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, Path) + .WithUrl(entity.Server.RequestUri.Scheme, entity.Server.RequestUri.Authority, Path) .WithNonce(claim.Nonce!) .Encrypt(entity, claim); //Sign and set the claim cookie - SignAndSetCookie(entity, claim); + _claims.SignAndSetCookie(entity, claim); webm.Success = true; //Response - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } /* @@ -295,16 +299,16 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Check for security navigation headers. This should be a browser redirect, if (!entity.Server.IsNavigation() || !entity.Server.IsUserInvoked()) { - ClearClaimData(entity); + _claims.ClearClaimData(entity); //The connection was not a browser redirect entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec"); return VfReturnType.VirtualSkip; } //Try to get the claim from the state parameter - if (!VerifyAndGetClaim(entity, out LoginClaim? claim)) + if (!_claims.VerifyAndGetClaim(entity, out LoginClaim? claim)) { - ClearClaimData(entity); + _claims.ClearClaimData(entity); entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired"); return VfReturnType.VirtualSkip; } @@ -312,7 +316,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Confirm the nonce matches the claim if (string.CompareOrdinal(claim.Nonce, state) != 0) { - ClearClaimData(entity); + _claims.ClearClaimData(entity); entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); return VfReturnType.VirtualSkip; } @@ -323,7 +327,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Token may be null if (token == null) { - ClearClaimData(entity); + _claims.ClearClaimData(entity); entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); return VfReturnType.VirtualSkip; } @@ -335,7 +339,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token); //Sign and set cookie - SignAndSetCookie(entity, claim); + _claims.SignAndSetCookie(entity, claim); //Prepare redirect entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}"); @@ -345,7 +349,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Check to see if there was an error code set if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) { - ClearClaimData(entity); + _claims.ClearClaimData(entity); Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]); entity.Redirect(RedirectType.Temporary, $"{Path}?result=error"); return VfReturnType.VirtualSkip; @@ -362,51 +366,44 @@ namespace VNLib.Plugins.Essentials.SocialOauth protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); - + //Get the finalization message using JsonDocument? request = await entity.GetJsonFromFileAsync(); if (webm.Assert(request != null, "Request message is required")) { - entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); } //Recover the nonce string? base32Nonce = request.RootElement.GetPropString("nonce"); - if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required")) + if(webm.Assert(base32Nonce != null, "Nonce parameter is required")) { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } //Validate nonce if (!NonceValidator.Validate(base32Nonce, webm)) { - entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); - return VfReturnType.VirtualSkip; + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); } //Recover the access token - bool cookieValid = VerifyAndGetClaim(entity, out LoginClaim? claim); - - if (webm.Assert(cookieValid, AUTH_ERROR_MESSAGE)) + if (webm.Assert(_claims.VerifyAndGetClaim(entity, out LoginClaim? claim), AUTH_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //We can clear the client's access claim - ClearClaimData(entity); + _claims.ClearClaimData(entity); //Confirm nonce matches the client's nonce string bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0; if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Safe to recover the access token @@ -417,90 +414,99 @@ namespace VNLib.Plugins.Essentials.SocialOauth if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } + + //Convert the platform user-id to a database-safe user-id + string computedId = Users.ComputeSafeUserId(userLogin.UserId!); //Fetch the user from the database - IUser? user = await Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation); + IUser? user = await Users.GetUserFromIDAsync(computedId, entity.EventCancellation); + /* + * If a user is not found, we can optionally create a new user account + * if the configuration allows it. + */ if(user == null) { + //make sure registration is enabled + if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + //Get the clients personal info to being login process AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation); if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } //Validate the account data if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE)) { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + return VirtualOk(entity, webm); } - //make sure registration is enabled - if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future - using PrivateString passhash = Passwords.GetRandomPassword(Config.RandomPasswordSize); - try - { - //Create the user with the specified email address, minimum privilage level, and an empty password - user = await Users.CreateUserAsync(userLogin.UserId!, userAccount.EmailAddress, AccountUtil.MINIMUM_LEVEL, passhash, entity.EventCancellation); - //Set active status - user.Status = UserStatus.Active; - //Store the new profile - user.SetProfile(userAccount); - //Set the account creation origin - user.SetAccountOrigin(Config.AccountOrigin); - } - catch(UserCreationFailedException) + //See if user by email address exists + user = await Users.GetUserFromEmailAsync(userAccount.EmailAddress!, entity.EventCancellation); + + if (user == null) { - Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured"); - webm.Result = "Please try again later"; - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; + //Create the new user account + UserCreationRequest creation = new() + { + EmailAddress = userAccount.EmailAddress!, + InitialStatus = UserStatus.Active, + }; + + try + { + //Create the user with the specified email address, minimum privilage level, and an empty password + user = await Users.CreateUserAsync(creation, computedId, entity.EventCancellation); + + //Store the new profile and origin + user.SetProfile(userAccount); + user.SetAccountOrigin(Config.AccountOrigin); + } + catch (UserCreationFailedException) + { + Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured"); + webm.Result = "Please try again later"; + return VirtualOk(entity, webm); + } + + //Skip check since we just created the user + goto Authorize; } + + /* + * User account already exists via email address but not + * user-id + */ } - else + + //Make sure local accounts are allowed + if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) { - //Check for local only - if (webm.Assert(!user.LocalOnly, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } + return VirtualOk(entity, webm); + } - //Make sure local accounts are allowed - if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } + //Reactivate inactive accounts + if (user.Status == UserStatus.Inactive) + { + user.Status = UserStatus.Active; + } - //Reactivate inactive accounts - if(user.Status == UserStatus.Inactive) - { - user.Status = UserStatus.Active; - } - - //Make sure the account is active - if(webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE)) - { - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } + //Make sure the account is active + if (webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); } - //Finalze login + Authorize: + try { //Generate authoization @@ -517,8 +523,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Set the success flag webm.Success = true; + //Write to log - Log.Debug("Successful login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp); + Log.Debug("Successful social login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp); + //release the user await user.ReleaseAsync(); } @@ -541,248 +549,24 @@ namespace VNLib.Plugins.Essentials.SocialOauth //destroy any login data on failure entity.InvalidateLogin(); - Log.Error(uue); + Log.Error("Failed to update the user's account cause:\n{err}",uue); } finally { user.Dispose(); } - entity.CloseResponse(webm); - return VfReturnType.VirtualSkip; - } - - private static bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) - { - claim = null; - - //Try to get the cookie - if (!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue)) - { - return false; - } - - //Recover the signing key from the user's session - string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; - byte[]? key = VnEncoding.FromBase32String(sigKey); - - if (key == null) - { - return false; - } - - try - { - //Try to parse the jwt - using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); - - //Verify the jwt - if (!jwt.Verify(key, HashAlg.SHA256)) - { - return false; - } - - //Recover the clam from the jwt - claim = jwt.GetPayload<LoginClaim>(); - - //Verify the expiration time - return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); - } - catch (FormatException) - { - //JWT was corrupted and could not be parsed - return false; - } - finally - { - MemoryUtil.InitializeBlock(key.AsSpan()); - } - } - - private static void ClearClaimData(HttpEntity entity) - { - //Remove the upgrade cookie - if (entity.Server.RequestCookies.ContainsKey(CLAIM_COOKIE_NAME)) - { - //Expire cookie - HttpCookie cookie = new(CLAIM_COOKIE_NAME, string.Empty) - { - Secure = true, - HttpOnly = true, - ValidFor = TimeSpan.Zero, - SameSite = CookieSameSite.SameSite - }; - - entity.Server.SetCookie(in cookie); - } - - //Clear the signing key from the session - entity.Session[SESSION_SIG_KEY_NAME] = null!; - } - - private void SignAndSetCookie(HttpEntity entity, LoginClaim claim) - { - //Setup Jwt - using JsonWebToken jwt = new(); - - //Write claim body, we dont need a header - jwt.WritePayload(claim, Statics.SR_OPTIONS); - - //Generate signing key - byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); - - //Sign the jwt - jwt.Sign(sigKey, HashAlg.SHA256); - - //Build and set cookie - HttpCookie cookie = new(CLAIM_COOKIE_NAME, jwt.Compile()) - { - Secure = true, - HttpOnly = true, - ValidFor = Config.InitClaimValidFor, - SameSite = CookieSameSite.SameSite, - Path = this.Path - }; - - entity.Server.SetCookie(in cookie); - - //Encode and store the signing key in the clien't session - entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase32String(sigKey); - - //Clear the signing key - MemoryUtil.InitializeBlock(sigKey.AsSpan()); - } - - /* - * Construct the client's redirect url based on their login claim, which contains - * a public key which can be used to encrypt the url so that only the client - * private-key holder can decrypt the url and redirect themselves to the - * target OAuth website. - * - * The result is an encrypted nonce that should guard against replay attacks and MITM - */ - - sealed record class LoginUriBuilder(OauthClientConfig Config) - { - private string? redirectUrl; - private string? nonce; - private Encoding _encoding = Encoding.UTF8; - - public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) - { - //Alloc stack buffer for url - Span<char> buffer = stackalloc char[1024]; - - //buffer writer for easier syntax - ForwardOnlyWriter<char> writer = new(buffer); - //first build the redirect url to re-encode it - writer.Append(scheme); - writer.Append("://"); - //Create redirect url (current page, default action is to authorize the client) - writer.Append(authority); - writer.Append(path); - //url encode the redirect path and save it for later - redirectUrl = Uri.EscapeDataString(writer.ToString()); - - return this; - } - - public LoginUriBuilder WithEncoding(Encoding encoding) - { - _encoding = encoding; - return this; - } - - public LoginUriBuilder WithNonce(string base32Nonce) - { - nonce = base32Nonce; - return this; - } - - public string Encrypt(HttpEntity client, IClientSecInfo secInfo) - { - //Alloc buffer and split it into binary and char buffers - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000); - - Span<byte> binBuffer = buffer.Span[2048..]; - Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]); - - - /* - * Build the character uri so we can encode it to binary, - * encrypt it and return it to the client - */ - - ForwardOnlyWriter<char> writer = new(charBuffer); - - //Append the config redirect path - writer.Append(Config.AccessCodeUrl.OriginalString); - //begin query arguments - writer.Append("&client_id="); - writer.Append(Config.ClientID.Value); - //add the redirect url - writer.Append("&redirect_uri="); - writer.Append(redirectUrl); - //Append the state parameter - writer.Append("&state="); - writer.Append(nonce); - - //Collect the written character data - ReadOnlySpan<char> url = writer.AsSpan(); - - //Separate bin buffers for encryption and encoding - Span<byte> encryptionBuffer = binBuffer[1024..]; - Span<byte> encodingBuffer = binBuffer[..1024]; - - //Encode the url to binary - int byteCount = _encoding.GetBytes(url, encodingBuffer); - - //Encrypt the binary data - ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); - - //base64 encode the encrypted - return Convert.ToBase64String(encryptionBuffer[0..(int)count]); - } + return VirtualOk(entity, webm); } + - - sealed class LoginClaim : IClientSecInfo + sealed class Oauth2TokenResult: OAuthAccessState { - [JsonPropertyName("exp")] - public long ExpirationSeconds { get; set; } - - [JsonPropertyName("iat")] - public long IssuedAtTime { get; set; } - - [JsonPropertyName("nonce")] - public string? Nonce { get; set; } - - [JsonPropertyName("locallanguage")] - public string? LocalLanguage { get; set; } - - [JsonPropertyName("pubkey")] - public string? PublicKey { get; set; } - - [JsonPropertyName("clientid")] - public string? ClientId { get; set; } - - - public void ComputeNonce(int nonceSize) - { - //Alloc nonce buffer - using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize); - try - { - //fill the buffer with random data - RandomHash.GetRandomBytes(buffer.Span); + [JsonPropertyName("error")] + public string? Error { get; set; } - //Base32-Encode nonce and save it - Nonce = VnEncoding.ToBase32String(buffer.Span); - } - finally - { - MemoryUtil.InitializeBlock(buffer.Span); - } - } + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } } + } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs index 7ebb37e..0ccda69 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Validators/AccountDataValidator.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -27,8 +27,6 @@ using FluentValidation; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Validation; -#nullable enable - namespace VNLib.Plugins.Essentials.SocialOauth.Validators { internal class AccountDataValidator : AbstractValidator<AccountData> @@ -39,36 +37,47 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Validators .NotEmpty() .WithMessage("Your account does not have an email address assigned to it"); - RuleFor(t => t.EmailAddress) - .EmailAddress() - .WithMessage("Your account does not have a valid email address assigned to it"); - - //Validate city - RuleFor(t => t.City).MaximumLength(50); - RuleFor(t => t.City).AlphaOnly(); - - RuleFor(t => t.Company).MaximumLength(50); - RuleFor(t => t.Company).SpecialCharacters(); + RuleFor(t => t.City) + .MaximumLength(35) + .AlphaOnly() + .When(t => t.City?.Length > 0); - RuleFor(t => t.First).MaximumLength(35); - RuleFor(t => t.First).AlphaOnly(); + RuleFor(t => t.Company) + .MaximumLength(50) + .SpecialCharacters() + .When(t => t.Company?.Length > 0); - RuleFor(t => t.Last).MaximumLength(35); - RuleFor(t => t.Last).AlphaOnly(); + //Require a first and last names to be set together + When(t => t.First?.Length > 0 || t.Last?.Length > 0, () => + { + RuleFor(t => t.First) + .Length(1, 35) + .AlphaOnly(); + RuleFor(t => t.Last) + .Length(1, 35) + .AlphaOnly(); + }); RuleFor(t => t.PhoneNumber) - .EmptyPhoneNumber() + .PhoneNumber() + .When(t => t.PhoneNumber?.Length > 0) .OverridePropertyName("Phone"); //State must be 2 characters for us states if set - RuleFor(t => t.State).Length(t => t.State?.Length != 0 ? 2 : 0); + RuleFor(t => t.State) + .Length(2) + .When(t => t.State?.Length > 0); - RuleFor(t => t.Street).MaximumLength(50); - RuleFor(t => t.Street).AlphaNumericOnly(); + RuleFor(t => t.Street) + .AlphaNumericOnly() + .MaximumLength(50) + .When(t => t.Street?.Length > 0); - RuleFor(t => t.Zip).NumericOnly(); //Allow empty zip codes, but if one is defined, is must be less than 7 characters - RuleFor(t => t.Zip).Length(ad => ad.Zip?.Length != 0 ? 7 : 0); + RuleFor(t => t.Zip) + .NumericOnly() + .MaximumLength(7) + .When(t => t.Zip?.Length > 0); } } } |