From 526c2364b9ad685d1c000fc8a168bf1305aaa8b7 Mon Sep 17 00:00:00 2001 From: vman Date: Fri, 18 Nov 2022 16:08:51 -0500 Subject: Add project files. --- .../src/Endpoints/RegistrationEntpoint.cs | 367 +++++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs (limited to 'VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs') diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs new file mode 100644 index 0000000..2551fbb --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs @@ -0,0 +1,367 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using System.Security.Cryptography; + +using FluentValidation; + +using Emails.Transactional.Client; +using Emails.Transactional.Client.Exceptions; + +using VNLib.Hashing; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Hashing.IdentityUtility; +using VNLib.Net.Rest.Client; +using VNLib.Net.Rest.Client.OAuth2; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using VNLib.Plugins.Extensions.Loading.Events; +using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation; +using static VNLib.Plugins.Essentials.Accounts.AccountManager; + + +namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints +{ + + [ConfigurationName("registration")] + internal sealed class RegistrationEntpoint : UnprotectedWebEndpoint, IIntervalScheduleable + { + /// + /// Generates a CNG random buffer to use as a nonce + /// + private static string EntropyNonce => RandomHash.GetRandomHex(16); + + 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 HMAC SigAlg => new HMACSHA256(RegSignatureKey.Result); + + private readonly IUserManager Users; + private readonly IValidator RegJwtValdidator; + private readonly PasswordHashing Passwords; + private readonly RevokedTokenStore RevokedTokens; + private readonly EmailSystemConfig Emails; + private readonly Task RegSignatureKey; + private readonly TimeSpan RegExpiresSec; + + /// + /// Creates back-end functionality for a "registration" or "sign-up" page that integrates with the plugin + /// + /// The path identifier + /// + public RegistrationEntpoint(PluginBase plugin, IReadOnlyDictionary config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, plugin.Log); + + RegExpiresSec = config["reg_expires_sec"].GetTimeSpan(TimeParseType.Seconds); + + //Init reg jwt validator + RegJwtValdidator = GetJwtValidator(); + + Passwords = plugin.GetPasswords(); + Users = plugin.GetUserManager(); + RevokedTokens = new(plugin.GetContextOptions()); + Emails = new(plugin); + + //Begin the async op to get the signature key from the vault + RegSignatureKey = plugin.TryGetSecretAsync("reg_sig_key").ContinueWith((ts) => { + + _ = ts.Result ?? throw new KeyNotFoundException("Missing required key 'reg_sig_key' in 'registration' configuration"); + return Convert.FromBase64String(ts.Result); + }); + + //Register timeout for cleanup + _ = plugin.ScheduleInterval(this, TimeSpan.FromSeconds(60)); + } + + private static IValidator GetJwtValidator() + { + InlineValidator 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; + } + + + protected override async ValueTask PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + //Get the json request data from client + using JsonDocument? request = await entity.GetJsonFromFileAsync(); + + if(webm.Assert(request != null, "No request data present")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the jwt string from client + string? regJwt = request.RootElement.GetPropString("token"); + using PrivateString? password = (PrivateString?)request.RootElement.GetPropString("password"); + + //validate inputs + { + 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; + } + } + + //Verify jwt has not been revoked + if(await RevokedTokens.IsRevokedAsync(regJwt, entity.EventCancellation)) + { + webm.Result = FAILED_AUTH_ERR; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + string emailAddress; + try + { + //get jwt + using JsonWebToken jwt = JsonWebToken.Parse(regJwt); + //verify signature + using (HMAC hmac = SigAlg) + { + bool verified = jwt.Verify(hmac); + + if (webm.Assert(verified, FAILED_AUTH_ERR)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + } + + //recover iat and email address + using JsonDocument reg = jwt.GetPayload(); + emailAddress = reg.RootElement.GetPropString("email")!; + DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(reg.RootElement.GetProperty("iat").GetInt64()); + + //Verify IAT against expiration at second resolution + if (webm.Assert(iat.Add(RegExpiresSec) > DateTimeOffset.UtcNow, FAILED_AUTH_ERR)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + } + catch (FormatException fe) + { + Log.Debug(fe); + webm.Result = FAILED_AUTH_ERR; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + + //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); + + //Set active status + user.Status = UserStatus.Active; + //set local account origin + user.SetAccountOrigin(LOCAL_ACCOUNT_ORIGIN); + + //set user verification + await user.ReleaseAsync(); + + //Revoke token now complete + _ = RevokedTokens.RevokeAsync(regJwt, 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; + } + //Capture creation failed, this may be a replay + catch (UserExistsException) + { + } + catch(UserCreationFailedException) + { + } + + webm.Result = FAILED_AUTH_ERR; + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + + private static readonly IReadOnlyDictionary JWT_HEADER = new Dictionary() + { + { "typ", "JWT" }, + { "alg", "HS256" } + }; + + protected override async ValueTask PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the request + RegRequestMessage? request = await entity.GetJsonFromFileAsync(); + if (webm.Assert(request != null, "Request is invalid")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the request + if (!AccountValidations.RegRequestValidator.Validate(request, webm)) + { + entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm); + return VfReturnType.VirtualSkip; + } + + //Create psudo contant time delay + Task delay = Task.Delay(200); + + //See if a user account already exists + using (IUser? user = await Users.GetUserFromEmailAsync(request.UserName!, entity.EventCancellation)) + { + if (user != null) + { + goto Exit; + } + } + + //Get exact timestamp + DateTimeOffset timeStamp = DateTimeOffset.UtcNow; + + //generate random nonce for entropy + string entropy = EntropyNonce; + + //Init client jwt + string jwtData; + using (JsonWebToken emailJwt = new()) + { + + emailJwt.WriteHeader(JWT_HEADER); + + //Init new claim stack, include the same iat time, nonce for entropy, and descriptor storage id + emailJwt.InitPayloadClaim(3) + .AddClaim("iat", timeStamp.ToUnixTimeSeconds()) + .AddClaim("n", entropy) + .AddClaim("email", request.UserName) + .CommitClaims(); + + //sign the jwt + using (HMAC hmac = SigAlg) + { + emailJwt.Sign(hmac); + } + //Compile to encoded string + jwtData = emailJwt.Compile(); + } + + string regUrl = $"https://{entity.Server.RequestUri.Authority}{Path}?t={jwtData}"; + + //Send email to user in background task and do not await it + _ = SendRegEmailAsync(request.UserName!, regUrl).ConfigureAwait(false); + + Exit: + //await sort of constant time delay + await delay; + + //Notify user + webm.Result = REG_ERR_MESSAGE; + webm.Success = true; + + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + + private async Task SendRegEmailAsync(string emailAddress, string url) + { + try + { + //Get a new registration template + EmailTransactionRequest emailTemplate = Emails.GetRegistrationMessage(); + //Add the user's to address + emailTemplate.AddToAddress(emailAddress); + emailTemplate.AddVariable("username", emailAddress); + //Set the security code variable string + emailTemplate.AddVariable("reg_url", url); + emailTemplate.AddVariable("date", DateTimeOffset.UtcNow.ToString("f")); + + //Get a new client contract + using ClientContract client = Emails.RestClientPool.Lease(); + //Send the email + TransactionResult result = await client.Resource.SendEmailAsync(emailTemplate); + if (!result.Success) + { + Log.Debug("Registration email failed to send, SMTP status code: {smtp}", result.SmtpStatus); + } + else + { + Log.Verbose("Registration email sent to user. Status {smtp}", result.SmtpStatus); + } + } + catch (ValidationFailedException vf) + { + //This should only occur if there is a bug in our reigration code that allowed an invalid value pass + Log.Debug(vf, "Registration email failed to send to user because data validation failed"); + } + catch (InvalidAuthorizationException iae) + { + Log.Warn(iae, "Registration email failed to send due to an authentication error"); + } + catch (OAuth2AuthenticationException o2e) + { + Log.Warn(o2e, "Registration email failed to send due to an authentication error"); + } + catch (Exception ex) + { + Log.Error(ex); + } + } + + async Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken) + { + //Cleanup tokens + await RevokedTokens.CleanTableAsync(RegExpiresSec, cancellationToken); + } + } +} \ No newline at end of file -- cgit