diff options
author | vman <public@vaughnnugent.com> | 2022-11-18 16:08:51 -0500 |
---|---|---|
committer | vman <public@vaughnnugent.com> | 2022-11-18 16:08:51 -0500 |
commit | 526c2364b9ad685d1c000fc8a168bf1305aaa8b7 (patch) | |
tree | a2bc01607320a6a75e1a869d5bd34e79fd63c595 /VNLib.Plugins.Essentials.Accounts.Registration | |
parent | 2080400119be00bdc354f3121d84ec2f89606ac7 (diff) |
Add project files.
Diffstat (limited to 'VNLib.Plugins.Essentials.Accounts.Registration')
12 files changed, 1247 insertions, 0 deletions
diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/.gitattributes b/VNLib.Plugins.Essentials.Accounts.Registration/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/.gitignore b/VNLib.Plugins.Essentials.Accounts.Registration/.gitignore new file mode 100644 index 0000000..f17dcd9 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/.gitignore @@ -0,0 +1,352 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +.json diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/readme.md b/VNLib.Plugins.Essentials.Accounts.Registration/readme.md new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/readme.md diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs new file mode 100644 index 0000000..839bc27 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs @@ -0,0 +1,109 @@ + +using FluentValidation; + +using VNLib.Plugins.Essentials.Accounts.Registration.Endpoints; +using VNLib.Plugins.Extensions.Validation; + +namespace VNLib.Plugins.Essentials.Accounts.Registration +{ + internal static class AccountValidations + { + /// <summary> + /// Central password requirement validator + /// </summary> + public static IValidator<string> PasswordValidator { get; } = GetPassVal(); + + public static IValidator<AccountData> AccountDataValidator { get; } = GetAcVal(); + + /// <summary> + /// A validator used to validate new registration request messages + /// </summary> + public static IValidator<RegRequestMessage> RegRequestValidator { get; } = GetRequestValidator(); + + static IValidator<string> GetPassVal() + { + InlineValidator<string> passVal = new(); + + passVal.RuleFor(static password => password) + .NotEmpty() + .Length(min: 8, max: 100) + .Password() + .WithMessage(errorMessage: "Password does not meet minium requirements"); + + return passVal; + } + + static IValidator<AccountData> GetAcVal() + { + InlineValidator<AccountData> adv = new (); + + //Validate city + + adv.RuleFor(t => t.City) + .MaximumLength(35) + .AlphaOnly() + .When(t => t.City?.Length > 0); + + adv.RuleFor(t => t.Company) + .MaximumLength(50) + .SpecialCharacters() + .When(t => t.Company?.Length > 0); + + //Require a first and last names to be set together + adv.When(t => t.First?.Length > 0 || t.Last?.Length > 0, () => + { + adv.RuleFor(t => t.First) + .Length(1, 35) + .AlphaOnly(); + adv.RuleFor(t => t.Last) + .Length(1, 35) + .AlphaOnly(); + }); + + adv.RuleFor(t => t.PhoneNumber) + .PhoneNumber() + .When(t => t.PhoneNumber?.Length > 0) + .OverridePropertyName("Phone"); + + //State must be 2 characters for us states if set + adv.RuleFor(t => t.State) + .Length(2) + .When(t => t.State?.Length > 0); + + adv.RuleFor(t => t.Street) + .AlphaNumericOnly() + .MaximumLength(50) + .When(t => t.Street?.Length > 0); + + //Allow empty zip codes, but if one is defined, is must be less than 7 characters + adv.RuleFor(t => t.Zip) + .NumericOnly() + .MaximumLength(7) + .When(t => t.Zip?.Length > 0); + + return adv; + } + + static IValidator<RegRequestMessage> GetRequestValidator() + { + InlineValidator<RegRequestMessage> reqVal = new(); + + reqVal.RuleFor(static s => s.ClientId) + .NotEmpty() + .IllegalCharacters() + .Length(1, 100); + + //Convert to universal time before validating + reqVal.RuleFor(static s => s.Timestamp.ToUniversalTime()) + .Must(t => t > DateTimeOffset.UtcNow.AddSeconds(-60) && t < DateTimeOffset.UtcNow.AddSeconds(60)); + + reqVal.RuleFor(static s => s.UserName) + .NotEmpty() + .EmailAddress() + .IllegalCharacters() + .Length(5, 50); + + return reqVal; + } + } +} diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/EmailSystemConfig.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/EmailSystemConfig.cs new file mode 100644 index 0000000..a0333c0 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/EmailSystemConfig.cs @@ -0,0 +1,126 @@ +using System; +using System.Text; +using System.Text.Json; + +using RestSharp; + +using Emails.Transactional.Client; + +using VNLib.Utils.Extensions; +using VNLib.Net.Rest.Client; +using VNLib.Net.Rest.Client.OAuth2; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Accounts.Registration +{ + /// <summary> + /// An extended <see cref="TransactionalEmailConfig"/> configuration + /// object that contains a <see cref="Net.Rest.Client.RestClientPool"/> pool for making + /// transactions + /// </summary> + internal sealed class EmailSystemConfig : TransactionalEmailConfig + { + public const string REG_TEMPLATE_NAME = "Registration"; + + public EmailSystemConfig(PluginBase pbase) + { + IReadOnlyDictionary<string, JsonElement> conf = pbase.GetConfig("email"); + EmailFromName = conf["from_name"].GetString() ?? throw new KeyNotFoundException(""); + EmailFromAddress = conf["from_address"].GetString() ?? throw new KeyNotFoundException(""); + Uri baseServerPath = new(conf["base_url"].GetString()!, UriKind.RelativeOrAbsolute); + Uri tokenServerBase = new(conf["token_server_url"].GetString()!, UriKind.RelativeOrAbsolute); + Uri transactionEndpoint = new(conf["transaction_path"].GetString()!, UriKind.RelativeOrAbsolute); + //Load templates + Dictionary<string, string> templates = conf["templates"].EnumerateObject().ToDictionary(jp => jp.Name, jp => jp.Value.GetString()!); + //Init base config + WithTemplates(templates) + .WithUrl(transactionEndpoint); + //Load credentials + string authEndpoint = conf["token_path"].GetString() ?? throw new KeyNotFoundException(); + int maxClients = conf["max_clients"].GetInt32(); + + + //Load oauth secrets from vault + Task<string?> oauth2ClientID = pbase.TryGetSecretAsync("oauth2_client_id"); + Task<string?> oauth2Password = pbase.TryGetSecretAsync("oauth2_client_secret"); + + //Lazy cred loaded, tasks should be loaded before this method will ever get called + Credential lazyCredentialGet() + { + //Load the results + string cliendId = oauth2ClientID.Result ?? throw new KeyNotFoundException("Missing required oauth2 client id"); + string password = oauth2Password.Result ?? throw new KeyNotFoundException("Missing required oauth2 client secret"); + + return Credential.Create(cliendId, password); + } + + + //Init client creation options + RestClientOptions poolOptions = new() + { + AllowMultipleDefaultParametersWithSameName = true, + AutomaticDecompression = System.Net.DecompressionMethods.All, + PreAuthenticate = true, + Encoding = Encoding.UTF8, + MaxTimeout = conf["request_timeout_ms"].GetInt32(), + UserAgent = "Essentials.EmailRegistation", + FollowRedirects = false, + BaseUrl = baseServerPath + }; + //Options for auth token endpoint + RestClientOptions oAuth2ClientOptions = new() + { + AllowMultipleDefaultParametersWithSameName = true, + AutomaticDecompression = System.Net.DecompressionMethods.All, + PreAuthenticate = false, + Encoding = Encoding.UTF8, + MaxTimeout = conf["request_timeout_ms"].GetInt32(), + UserAgent = "Essentials.EmailRegistation", + FollowRedirects = false, + BaseUrl = baseServerPath + }; + + //Init Oauth authenticator + OAuth2Authenticator authenticator = new(oAuth2ClientOptions, lazyCredentialGet, authEndpoint); + //Store pool + RestClientPool = new(maxClients, poolOptions, authenticator:authenticator); + + void Cleanup() + { + authenticator.Dispose(); + RestClientPool.Dispose(); + } + + //register password cleanup + _ = pbase.UnloadToken.RegisterUnobserved(Cleanup); + } + + /// <summary> + /// A shared <see cref="Net.Rest.Client.RestClientPool"/> for renting configuraed + /// <see cref="RestClient"/> + /// </summary> + public RestClientPool RestClientPool { get; } + /// <summary> + /// A global from email address name + /// </summary> + public string EmailFromName { get; } + /// <summary> + /// A global from email address + /// </summary> + public string EmailFromAddress { get; } + + /// <summary> + /// Prepares a new registration email transaction request + /// </summary> + /// <returns>The prepared <see cref="EmailTransactionRequest"/> object</returns> + public EmailTransactionRequest GetRegistrationMessage() + { + EmailTransactionRequest req = GetTemplateRequest(REG_TEMPLATE_NAME); + req.FromAddress = EmailFromAddress; + req.FromName = EmailFromName; + //set reg subject + req.Subject = "One more step to register"; + return req; + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegRequestMessage.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegRequestMessage.cs new file mode 100644 index 0000000..a151a86 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegRequestMessage.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace VNLib.Plugins.Essentials.Accounts.Registration.Endpoints +{ + internal class RegRequestMessage + { + [JsonPropertyName("localtime")] + public DateTimeOffset Timestamp { get; set; } + + [JsonPropertyName("username")] + public string? UserName { get; set; } + + [JsonPropertyName("clientid")] + public string? ClientId { get; set; } + } +}
\ No newline at end of file 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 + { + /// <summary> + /// Generates a CNG random buffer to use as a nonce + /// </summary> + 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<string> RegJwtValdidator; + private readonly PasswordHashing Passwords; + private readonly RevokedTokenStore RevokedTokens; + private readonly EmailSystemConfig Emails; + private readonly Task<byte[]> RegSignatureKey; + private readonly TimeSpan RegExpiresSec; + + /// <summary> + /// Creates back-end functionality for a "registration" or "sign-up" page that integrates with the <see cref="AccountManager"/> plugin + /// </summary> + /// <param name="Path">The path identifier</param> + /// <exception cref="ArgumentException"></exception> + public RegistrationEntpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> 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<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; + } + + + protected override async ValueTask<VfReturnType> 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<string, string> JWT_HEADER = new Dictionary<string, string>() + { + { "typ", "JWT" }, + { "alg", "HS256" } + }; + + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the request + RegRequestMessage? request = await entity.GetJsonFromFileAsync<RegRequestMessage>(); + 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 diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs new file mode 100644 index 0000000..000c9bd --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs @@ -0,0 +1,42 @@ + +using VNLib.Utils.Logging; + +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Essentials.Accounts.Registration.Endpoints; + +namespace VNLib.Plugins.Essentials.Accounts.Registration +{ + public sealed class RegistrationEntryPoint : PluginBase + { + public override string PluginName => "Essentials.EmailRegistration"; + + protected override void OnLoad() + { + try + { + //Route reg endpoint + this.Route<RegistrationEntpoint>(); + + Log.Information("Plugin loaded"); + } + catch(KeyNotFoundException kne) + { + Log.Error("Missing required configuration variables: {ex}", kne.Message); + } + } + + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + protected override void ProcessHostCommand(string cmd) + { + if (!this.IsDebug()) + { + return; + } + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevocationContext.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevocationContext.cs new file mode 100644 index 0000000..71921c2 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevocationContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +using VNLib.Plugins.Extensions.Data; + +namespace VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation +{ + internal class RevocationContext : TransactionalDbContext + { + public DbSet<RevokedToken> RevokedRegistrationTokens { get; set; } + + public RevocationContext(DbContextOptions options) : base(options) + {} + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedToken.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedToken.cs new file mode 100644 index 0000000..ac0fc9a --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedToken.cs @@ -0,0 +1,19 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation +{ + + internal class RevokedToken + { + /// <summary> + /// The time the token was revoked. + /// </summary> + public DateTime Created { get; set; } + /// <summary> + /// The token that was revoked. + /// </summary> + [Key] + public string? Token { get; set; } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedTokenStore.cs b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedTokenStore.cs new file mode 100644 index 0000000..ccc7b37 --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedTokenStore.cs @@ -0,0 +1,77 @@ +using System.Collections; + +using Microsoft.EntityFrameworkCore; + +using VNLib.Utils; + +namespace VNLib.Plugins.Essentials.Accounts.Registration.TokenRevocation +{ + internal class RevokedTokenStore + { + private readonly DbContextOptions Options; + + public RevokedTokenStore(DbContextOptions options) + { + Options = options; + } + + public async Task<bool> IsRevokedAsync(string token, CancellationToken cancellation) + { + await using RevocationContext context = new (Options); + await context.OpenTransactionAsync(cancellation); + + //Select any that match tokens + bool any = await (from t in context.RevokedRegistrationTokens + where t.Token == token + select t).AnyAsync(cancellation); + + await context.CommitTransactionAsync(cancellation); + return any; + } + + public async Task RevokeAsync(string token, CancellationToken cancellation) + { + await using RevocationContext context = new (Options); + await context.OpenTransactionAsync(cancellation); + + //Add to table + context.RevokedRegistrationTokens.Add(new RevokedToken() + { + Created = DateTime.UtcNow, + Token = token + }); + + //Save changes and commit transaction + await context.SaveChangesAsync(cancellation); + await context.CommitTransactionAsync(cancellation); + } + + /// <summary> + /// Removes expired records from the store + /// </summary> + /// <param name="validFor">The time a token is valid for</param> + /// <param name="cancellation">A token that cancels the async operation</param> + /// <returns>The number of records evicted from the store</returns> + public async Task<ERRNO> CleanTableAsync(TimeSpan validFor, CancellationToken cancellation) + { + DateTime expiredBefore = DateTime.UtcNow.Subtract(validFor); + + await using RevocationContext context = new (Options); + await context.OpenTransactionAsync(cancellation); + + //Select any that match tokens + RevokedToken[] expired = await context.RevokedRegistrationTokens.Where(t => t.Created < expiredBefore) + .Select(static t => t) + .ToArrayAsync(cancellation); + + + context.RevokedRegistrationTokens.RemoveRange(expired); + + ERRNO count =await context.SaveChangesAsync(cancellation); + + await context.CommitTransactionAsync(cancellation); + + return count; + } + } +}
\ No newline at end of file diff --git a/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj b/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj new file mode 100644 index 0000000..5f6a23c --- /dev/null +++ b/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj @@ -0,0 +1,62 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <PlatformTarget>x64</PlatformTarget> + <GenerateDocumentationFile>False</GenerateDocumentationFile> + <Title>VNLib.Plugins.Essentials.Accounts.Registration</Title> + <Authors>Vaughn Nugent</Authors> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl> + <ProduceReferenceAssembly>False</ProduceReferenceAssembly> + <SignAssembly>False</SignAssembly> + <AssemblyVersion>1.0.0.1</AssemblyVersion> + <AssemblyName>Essentials.EmailRegistration</AssemblyName> + <Platforms>AnyCPU;x64</Platforms> + </PropertyGroup> + + <!-- Resolve nuget dll files and store them in the output dir --> + <PropertyGroup> + <!--Enable dynamic loading--> + <EnableDynamicLoading>true</EnableDynamicLoading> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <CheckForOverflowUnderflow>True</CheckForOverflowUnderflow> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <CheckForOverflowUnderflow>True</CheckForOverflowUnderflow> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> + <CheckForOverflowUnderflow>True</CheckForOverflowUnderflow> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <CheckForOverflowUnderflow>True</CheckForOverflowUnderflow> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\VNLib\Plugins\VNLib.Plugins.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Data\VNLib.Plugins.Extensions.Data.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" /> + <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" /> + <ProjectReference Include="..\..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" /> + <ProjectReference Include="..\..\..\VNLib.Net.Rest.Client\VNLib.Net.Rest.Client.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="Essentials.EmailRegistration.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <Target Name="PostBuild" AfterTargets="PostBuildEvent"> + <Exec Command="start xcopy "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> |