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); } } }