From 332abae3129d1f2a5aa29dd93a9e3dbd3366efdc Mon Sep 17 00:00:00 2001 From: Mia Winter Date: Tue, 13 Feb 2024 13:38:57 +0100 Subject: [PATCH] Changed email tokens to be generated and validated in EmailTemplateService --- Wave/Components/Pages/EmailSignup.razor | 80 +++++++++++-------------- Wave/Services/EmailTemplateService.cs | 30 +++++++++- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/Wave/Components/Pages/EmailSignup.razor b/Wave/Components/Pages/EmailSignup.razor index 1b04b21..297ef28 100644 --- a/Wave/Components/Pages/EmailSignup.razor +++ b/Wave/Components/Pages/EmailSignup.razor @@ -7,13 +7,11 @@ @using System.Net @using Microsoft.AspNetCore.Identity.UI.Services @using Microsoft.EntityFrameworkCore -@using Microsoft.Extensions.Caching.Distributed @using Wave.Services @inject ILogger Logger @inject IStringLocalizer Localizer @inject IDbContextFactory ContextFactory -@inject IDistributedCache TokenCache @inject IOptions Features @inject IOptions Customizations @inject NavigationManager Navigation @@ -60,32 +58,28 @@ private string Message { get; set; } = string.Empty; - protected override void OnInitialized() { + protected override async Task OnInitializedAsync() { if (Features.Value.EmailSubscriptions is not true) throw new ApplicationException("Email subscriptions not enabled."); if (Id is null || Token is null) return; - try { - string cacheKey = "subscribe-" + Id; - byte[]? tokenInCache = TokenCache.Get(cacheKey); + var id = await TemplateService.ValidateTokensAsync(Id, Token); - if (tokenInCache is null || Token != Convert.ToBase64String(tokenInCache)) { + if (id is null) { Message = Localizer["Failure_Message"]; return; } - var realId = new Guid(Convert.FromBase64String(Id)); - using var context = ContextFactory.CreateDbContext(); - var subscriber = context.Set().IgnoreQueryFilters().FirstOrDefault(s => s.Id == realId); + await using var context = await ContextFactory.CreateDbContextAsync(); + var subscriber = context.Set().IgnoreQueryFilters().FirstOrDefault(s => s.Id == id); if (subscriber is null) { Message = Localizer["Failure_Message"]; return; } subscriber.Unsubscribed = false; - context.SaveChanges(); + await context.SaveChangesAsync(); - TokenCache.Remove(cacheKey); Message = Localizer["Success_Message"]; } catch (Exception ex) { Logger.LogError(ex, "Error trying to confirm subscriber."); @@ -97,43 +91,41 @@ if (Features.Value.EmailSubscriptions is not true) throw new ApplicationException("Email subscriptions not enabled."); - Message = Localizer["Submit_Message"]; - await using var context = await ContextFactory.CreateDbContextAsync(); + try { + Message = Localizer["Submit_Message"]; + await using var context = await ContextFactory.CreateDbContextAsync(); - var subscriber = context.Set().IgnoreQueryFilters().FirstOrDefault(s => s.Email == Model.Email); - if (subscriber?.Unsubscribed is false) return; + var subscriber = context.Set().IgnoreQueryFilters().FirstOrDefault(s => s.Email == Model.Email); + if (subscriber?.Unsubscribed is false) return; - subscriber ??= new EmailSubscriber { - Email = Model.Email.Trim(), - Unsubscribed = true - }; - subscriber.Name = Model.Name; - context.Update(subscriber); - await context.SaveChangesAsync(); + subscriber ??= new EmailSubscriber { + Email = Model.Email.Trim(), + Unsubscribed = true + }; + subscriber.Name = Model.Name; + context.Update(subscriber); + await context.SaveChangesAsync(); - if (subscriber.Unsubscribed) { - string id = Convert.ToBase64String(subscriber.Id.ToByteArray()); - string token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + if (subscriber.Unsubscribed) { + (string id, string token) = await TemplateService.CreateConfirmTokensAsync(subscriber.Id); - await TokenCache.SetAsync("subscribe-" + id, - Convert.FromBase64String(token), - new DistributedCacheEntryOptions { - AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) - }); - - string confirmLink = Navigation.ToAbsoluteUri( - $"/Email/Confirm?user={WebUtility.UrlEncode(id)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; + string confirmLink = Navigation.ToAbsoluteUri( + $"/Email/Confirm?user={WebUtility.UrlEncode(id)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; - var customization = Customizations.Value; - string body = TemplateService.Default( - Navigation.BaseUri, - !string.IsNullOrWhiteSpace(customization.LogoLink) ? - customization.LogoLink : - Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri, - Localizer["ConfirmEmailTitle"], - string.Format(Localizer["ConfirmEmailBody"], customization.AppName) + - $"""

{Localizer["Submit"]}

"""); - await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body); + var customization = Customizations.Value; + string body = TemplateService.Default( + Navigation.BaseUri, + !string.IsNullOrWhiteSpace(customization.LogoLink) ? + customization.LogoLink : + Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri, + Localizer["ConfirmEmailTitle"], + string.Format(Localizer["ConfirmEmailBody"], customization.AppName) + + $"""

{Localizer["Submit"]}

"""); + await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body); + } + } catch (Exception ex) { + Logger.LogError(ex, "Failed to create subscriber/send confirmation mail."); + Message = Localizer["Failure_Message"]; } } diff --git a/Wave/Services/EmailTemplateService.cs b/Wave/Services/EmailTemplateService.cs index 97b72ca..87bb46c 100644 --- a/Wave/Services/EmailTemplateService.cs +++ b/Wave/Services/EmailTemplateService.cs @@ -1,18 +1,46 @@ using System.Text.RegularExpressions; +using Microsoft.Extensions.Caching.Distributed; using Mjml.Net; namespace Wave.Services; -public partial class EmailTemplateService(ILogger logger) { +public partial class EmailTemplateService(ILogger logger, IDistributedCache tokenCache) { public enum Constants { BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink } private ILogger Logger { get; } = logger; private IMjmlRenderer Renderer { get; } = new MjmlRenderer(); + private IDistributedCache TokenCache { get; } = tokenCache; private Regex TokenMatcher { get; } = MyRegex(); + public async Task<(string user, string token)> CreateConfirmTokensAsync(Guid subscriberId) { + string user = Convert.ToBase64String(subscriberId.ToByteArray()); + string token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + string cacheKey = "subscribe-" + user; + + await TokenCache.SetAsync(cacheKey, + Convert.FromBase64String(token), + new DistributedCacheEntryOptions { + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) + }); + + return (user, token); + } + + public async Task ValidateTokensAsync(string user, string token) { + string cacheKey = "subscribe-" + user; + byte[]? tokenInCache = await TokenCache.GetAsync(cacheKey); + + if (tokenInCache is null || token != Convert.ToBase64String(tokenInCache)) + return null; + + await TokenCache.RemoveAsync(cacheKey); + return new Guid(Convert.FromBase64String(user)); + } + + public string Default(string url, string logoLink, string title, string body) { return Process("default", new Dictionary { {Constants.HomeLink, url},