aboutsummaryrefslogtreecommitdiff
path: root/VNLib.Plugins.Essentials.Accounts.Registration/src
diff options
context:
space:
mode:
Diffstat (limited to 'VNLib.Plugins.Essentials.Accounts.Registration/src')
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/AccountValidations.cs109
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/EmailSystemConfig.cs126
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegRequestMessage.cs16
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/Endpoints/RegistrationEntpoint.cs367
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/RegistrationEntryPoint.cs42
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevocationContext.cs14
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedToken.cs19
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/TokenRevocation/RevokedTokenStore.cs77
-rw-r--r--VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj62
9 files changed, 832 insertions, 0 deletions
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 &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>