Changed email tokens to be generated and validated in EmailTemplateService

This commit is contained in:
Mia Rose Winter 2024-02-13 13:38:57 +01:00
parent c0afba7554
commit 332abae312
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
2 changed files with 65 additions and 45 deletions

View file

@ -7,13 +7,11 @@
@using System.Net @using System.Net
@using Microsoft.AspNetCore.Identity.UI.Services @using Microsoft.AspNetCore.Identity.UI.Services
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Caching.Distributed
@using Wave.Services @using Wave.Services
@inject ILogger<EmailSignup> Logger @inject ILogger<EmailSignup> Logger
@inject IStringLocalizer<EmailSignup> Localizer @inject IStringLocalizer<EmailSignup> Localizer
@inject IDbContextFactory<ApplicationDbContext> ContextFactory @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject IDistributedCache TokenCache
@inject IOptions<Features> Features @inject IOptions<Features> Features
@inject IOptions<Customization> Customizations @inject IOptions<Customization> Customizations
@inject NavigationManager Navigation @inject NavigationManager Navigation
@ -60,32 +58,28 @@
private string Message { get; set; } = string.Empty; private string Message { get; set; } = string.Empty;
protected override void OnInitialized() { protected override async Task OnInitializedAsync() {
if (Features.Value.EmailSubscriptions is not true) if (Features.Value.EmailSubscriptions is not true)
throw new ApplicationException("Email subscriptions not enabled."); throw new ApplicationException("Email subscriptions not enabled.");
if (Id is null || Token is null) return; if (Id is null || Token is null) return;
try { try {
string cacheKey = "subscribe-" + Id; var id = await TemplateService.ValidateTokensAsync(Id, Token);
byte[]? tokenInCache = TokenCache.Get(cacheKey);
if (tokenInCache is null || Token != Convert.ToBase64String(tokenInCache)) { if (id is null) {
Message = Localizer["Failure_Message"]; Message = Localizer["Failure_Message"];
return; return;
} }
var realId = new Guid(Convert.FromBase64String(Id)); await using var context = await ContextFactory.CreateDbContextAsync();
using var context = ContextFactory.CreateDbContext(); var subscriber = context.Set<EmailSubscriber>().IgnoreQueryFilters().FirstOrDefault(s => s.Id == id);
var subscriber = context.Set<EmailSubscriber>().IgnoreQueryFilters().FirstOrDefault(s => s.Id == realId);
if (subscriber is null) { if (subscriber is null) {
Message = Localizer["Failure_Message"]; Message = Localizer["Failure_Message"];
return; return;
} }
subscriber.Unsubscribed = false; subscriber.Unsubscribed = false;
context.SaveChanges(); await context.SaveChangesAsync();
TokenCache.Remove(cacheKey);
Message = Localizer["Success_Message"]; Message = Localizer["Success_Message"];
} catch (Exception ex) { } catch (Exception ex) {
Logger.LogError(ex, "Error trying to confirm subscriber."); Logger.LogError(ex, "Error trying to confirm subscriber.");
@ -97,43 +91,41 @@
if (Features.Value.EmailSubscriptions is not true) if (Features.Value.EmailSubscriptions is not true)
throw new ApplicationException("Email subscriptions not enabled."); throw new ApplicationException("Email subscriptions not enabled.");
Message = Localizer["Submit_Message"]; try {
await using var context = await ContextFactory.CreateDbContextAsync(); Message = Localizer["Submit_Message"];
await using var context = await ContextFactory.CreateDbContextAsync();
var subscriber = context.Set<EmailSubscriber>().IgnoreQueryFilters().FirstOrDefault(s => s.Email == Model.Email); var subscriber = context.Set<EmailSubscriber>().IgnoreQueryFilters().FirstOrDefault(s => s.Email == Model.Email);
if (subscriber?.Unsubscribed is false) return; if (subscriber?.Unsubscribed is false) return;
subscriber ??= new EmailSubscriber { subscriber ??= new EmailSubscriber {
Email = Model.Email.Trim(), Email = Model.Email.Trim(),
Unsubscribed = true Unsubscribed = true
}; };
subscriber.Name = Model.Name; subscriber.Name = Model.Name;
context.Update(subscriber); context.Update(subscriber);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
if (subscriber.Unsubscribed) { if (subscriber.Unsubscribed) {
string id = Convert.ToBase64String(subscriber.Id.ToByteArray()); (string id, string token) = await TemplateService.CreateConfirmTokensAsync(subscriber.Id);
string token = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
await TokenCache.SetAsync("subscribe-" + id, string confirmLink = Navigation.ToAbsoluteUri(
Convert.FromBase64String(token), $"/Email/Confirm?user={WebUtility.UrlEncode(id)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri;
new DistributedCacheEntryOptions {
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1)
});
string confirmLink = Navigation.ToAbsoluteUri( var customization = Customizations.Value;
$"/Email/Confirm?user={WebUtility.UrlEncode(id)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri; string body = TemplateService.Default(
Navigation.BaseUri,
var customization = Customizations.Value; !string.IsNullOrWhiteSpace(customization.LogoLink) ?
string body = TemplateService.Default( customization.LogoLink :
Navigation.BaseUri, Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri,
!string.IsNullOrWhiteSpace(customization.LogoLink) ? Localizer["ConfirmEmailTitle"],
customization.LogoLink : string.Format(Localizer["ConfirmEmailBody"], customization.AppName) +
Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri, $"""<p style="text-align: center"><a href="{confirmLink}">{Localizer["Submit"]}</a></p>""");
Localizer["ConfirmEmailTitle"], await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body);
string.Format(Localizer["ConfirmEmailBody"], customization.AppName) + }
$"""<p style="text-align: center"><a href="{confirmLink}">{Localizer["Submit"]}</a></p>"""); } catch (Exception ex) {
await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body); Logger.LogError(ex, "Failed to create subscriber/send confirmation mail.");
Message = Localizer["Failure_Message"];
} }
} }

View file

@ -1,18 +1,46 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Distributed;
using Mjml.Net; using Mjml.Net;
namespace Wave.Services; namespace Wave.Services;
public partial class EmailTemplateService(ILogger<EmailTemplateService> logger) { public partial class EmailTemplateService(ILogger<EmailTemplateService> logger, IDistributedCache tokenCache) {
public enum Constants { public enum Constants {
BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink BrowserLink, HomeLink, ContentLogo, ContentTitle, ContentBody, EmailUnsubscribeLink
} }
private ILogger<EmailTemplateService> Logger { get; } = logger; private ILogger<EmailTemplateService> Logger { get; } = logger;
private IMjmlRenderer Renderer { get; } = new MjmlRenderer(); private IMjmlRenderer Renderer { get; } = new MjmlRenderer();
private IDistributedCache TokenCache { get; } = tokenCache;
private Regex TokenMatcher { get; } = MyRegex(); 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<Guid?> 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) { public string Default(string url, string logoLink, string title, string body) {
return Process("default", new Dictionary<Constants, object?> { return Process("default", new Dictionary<Constants, object?> {
{Constants.HomeLink, url}, {Constants.HomeLink, url},