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/src | |
parent | 2080400119be00bdc354f3121d84ec2f89606ac7 (diff) |
Add project files.
Diffstat (limited to 'VNLib.Plugins.Essentials.Accounts.Registration/src')
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 "$(TargetDir)" "F:\Programming\Web Plugins\DevPlugins\$(TargetName)" /E /Y /R" /> + </Target> + +</Project> |