From 11dff4f0cc969ab24bb34a9c6e1b3b5cd081f471 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Thu, 10 Apr 2025 12:19:49 +0500 Subject: [PATCH] identity: move email sender to common --- Streetwriters.Common/Helpers/HtmlHelper.cs | 23 ++ .../Interfaces/IEmailSender.cs | 19 ++ Streetwriters.Common/Models/EmailTemplate.cs | 11 + Streetwriters.Common/Services/EmailSender.cs | 134 +++++++++ .../Streetwriters.Common.csproj | 3 + .../Controllers/AccountController.cs | 6 +- .../Controllers/IdentityControllerBase.cs | 4 +- .../Controllers/MFAController.cs | 2 +- .../Controllers/SignupController.cs | 2 +- ...mailSender.cs => ITemplatedEmailSender.cs} | 66 ++--- .../Models/EmailTemplate.cs | 33 --- .../Services/EmailSender.cs | 276 ------------------ Streetwriters.Identity/Services/MFAService.cs | 4 +- .../Services/TemplatedEmailSender.cs | 184 ++++++++++++ Streetwriters.Identity/Startup.cs | 2 + .../Streetwriters.Identity.csproj | 5 +- .../Validation/MFAGrantValidator.cs | 4 +- .../Validation/MFAPasswordGrantValidator.cs | 4 +- 18 files changed, 422 insertions(+), 360 deletions(-) create mode 100644 Streetwriters.Common/Helpers/HtmlHelper.cs create mode 100644 Streetwriters.Common/Interfaces/IEmailSender.cs create mode 100644 Streetwriters.Common/Models/EmailTemplate.cs create mode 100644 Streetwriters.Common/Services/EmailSender.cs rename Streetwriters.Identity/Interfaces/{IEmailSender.cs => ITemplatedEmailSender.cs} (94%) delete mode 100644 Streetwriters.Identity/Models/EmailTemplate.cs delete mode 100644 Streetwriters.Identity/Services/EmailSender.cs create mode 100644 Streetwriters.Identity/Services/TemplatedEmailSender.cs diff --git a/Streetwriters.Common/Helpers/HtmlHelper.cs b/Streetwriters.Common/Helpers/HtmlHelper.cs new file mode 100644 index 0000000..78e85c0 --- /dev/null +++ b/Streetwriters.Common/Helpers/HtmlHelper.cs @@ -0,0 +1,23 @@ +using System.IO; +using WebMarkupMin.Core; +using WebMarkupMin.Core.Loggers; + +namespace Streetwriters.Common.Helpers +{ + public static class HtmlHelper + { + public static string ReadMinifiedHtmlFile(string path) + { + var settings = new HtmlMinificationSettings() + { + WhitespaceMinificationMode = WhitespaceMinificationMode.Medium, + }; + var cssMinifier = new KristensenCssMinifier(); + var jsMinifier = new CrockfordJsMinifier(); + + var minifier = new HtmlMinifier(settings, cssMinifier, jsMinifier, new NullLogger()); + + return minifier.Minify(File.ReadAllText(path), false).MinifiedContent; + } + } +} diff --git a/Streetwriters.Common/Interfaces/IEmailSender.cs b/Streetwriters.Common/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..ab6cbcd --- /dev/null +++ b/Streetwriters.Common/Interfaces/IEmailSender.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MimeKit; +using MimeKit.Cryptography; +using Streetwriters.Common.Models; + +namespace Streetwriters.Common.Interfaces +{ + public interface IEmailSender + { + Task SendEmailAsync( + string email, + EmailTemplate template, + IClient client, + GnuPGContext gpgContext = null, + Dictionary attachments = null + ); + } +} diff --git a/Streetwriters.Common/Models/EmailTemplate.cs b/Streetwriters.Common/Models/EmailTemplate.cs new file mode 100644 index 0000000..76bd162 --- /dev/null +++ b/Streetwriters.Common/Models/EmailTemplate.cs @@ -0,0 +1,11 @@ +namespace Streetwriters.Common.Models +{ + public class EmailTemplate + { + public int? Id { get; set; } + public object Data { get; set; } + public string Subject { get; set; } + public string Html { get; set; } + public string Text { get; set; } + } +} diff --git a/Streetwriters.Common/Services/EmailSender.cs b/Streetwriters.Common/Services/EmailSender.cs new file mode 100644 index 0000000..61d1393 --- /dev/null +++ b/Streetwriters.Common/Services/EmailSender.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using MailKit.Net.Smtp; +using MimeKit; +using MimeKit.Cryptography; +using Org.BouncyCastle.Bcpg; +using Scriban; +using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Models; + +namespace Streetwriters.Common.Services +{ + public class EmailSender : IEmailSender, IAsyncDisposable + { + private readonly SmtpClient mailClient = new(); + + public async Task SendEmailAsync( + string email, + EmailTemplate template, + IClient client, + GnuPGContext gpgContext = null, + Dictionary attachments = null + ) + { + if (!mailClient.IsConnected) + { + if (int.TryParse(Common.Constants.SMTP_PORT, out int port)) + { + await mailClient.ConnectAsync( + Common.Constants.SMTP_HOST, + port, + MailKit.Security.SecureSocketOptions.Auto + ); + } + else + { + throw new InvalidDataException("SMTP_PORT is not a valid integer value."); + } + } + + if (!mailClient.IsAuthenticated) + await mailClient.AuthenticateAsync( + Common.Constants.SMTP_USERNAME, + Common.Constants.SMTP_PASSWORD + ); + + var message = new MimeMessage(); + var sender = new MailboxAddress(client.SenderName, client.SenderEmail); + message.From.Add(sender); + message.To.Add(new MailboxAddress("", email)); + message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data); + + if (!string.IsNullOrEmpty(Common.Constants.SMTP_REPLYTO_EMAIL)) + message.ReplyTo.Add(MailboxAddress.Parse(Common.Constants.SMTP_REPLYTO_EMAIL)); + + message.Body = await GetEmailBodyAsync( + template, + client, + sender, + gpgContext, + attachments + ); + + await mailClient.SendAsync(message); + } + + private static async Task GetEmailBodyAsync( + EmailTemplate template, + IClient client, + MailboxAddress sender, + GnuPGContext gpgContext = null, + Dictionary attachments = null + ) + { + var builder = new BodyBuilder(); + try + { + builder.TextBody = await Template.Parse(template.Text).RenderAsync(template.Data); + builder.HtmlBody = await Template.Parse(template.Html).RenderAsync(template.Data); + + if (attachments != null) + { + foreach (var attachment in attachments) + { + builder.Attachments.Add(attachment.Key, attachment.Value); + } + } + + var key = gpgContext?.GetSigningKey(sender); + if (key != null) + { + using (MemoryStream outputStream = new()) + { + using (Stream armoredStream = new ArmoredOutputStream(outputStream)) + { + key.PublicKey.Encode(armoredStream); + } + outputStream.Seek(0, SeekOrigin.Begin); + builder.Attachments.Add( + $"{client.Id}_pub.asc", + Encoding.ASCII.GetBytes( + Encoding.ASCII.GetString(outputStream.ToArray()) + ) + ); + } + return await MultipartSigned.CreateAsync( + gpgContext, + sender, + DigestAlgorithm.Sha256, + builder.ToMessageBody() + ); + } + else + { + return builder.ToMessageBody(); + } + } + catch (Exception ex) + { + await Slogger.Error("GetEmailBodyAsync", ex.ToString()); + return builder.ToMessageBody(); + } + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await mailClient.DisconnectAsync(true); + mailClient.Dispose(); + } + } +} diff --git a/Streetwriters.Common/Streetwriters.Common.csproj b/Streetwriters.Common/Streetwriters.Common.csproj index 64fb436..0399971 100644 --- a/Streetwriters.Common/Streetwriters.Common.csproj +++ b/Streetwriters.Common/Streetwriters.Common.csproj @@ -6,13 +6,16 @@ + + + diff --git a/Streetwriters.Identity/Controllers/AccountController.cs b/Streetwriters.Identity/Controllers/AccountController.cs index 3fdf004..a9b2911 100644 --- a/Streetwriters.Identity/Controllers/AccountController.cs +++ b/Streetwriters.Identity/Controllers/AccountController.cs @@ -52,10 +52,8 @@ namespace Streetwriters.Identity.Controllers { private IPersistedGrantStore PersistedGrantStore { get; set; } private ITokenGenerationService TokenGenerationService { get; set; } - private IUserClaimsPrincipalFactory PrincipalFactory { get; set; } - private IdentityServerOptions ISOptions { get; set; } private IUserAccountService UserAccountService { get; set; } - public AccountController(UserManager _userManager, IEmailSender _emailSender, + public AccountController(UserManager _userManager, ITemplatedEmailSender _emailSender, SignInManager _signInManager, RoleManager _roleManager, IPersistedGrantStore store, ITokenGenerationService tokenGenerationService, IMFAService _mfaService, IUserAccountService userAccountService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) { @@ -341,4 +339,4 @@ namespace Streetwriters.Identity.Controllers }); } } -} \ No newline at end of file +} diff --git a/Streetwriters.Identity/Controllers/IdentityControllerBase.cs b/Streetwriters.Identity/Controllers/IdentityControllerBase.cs index 7c428c7..5c19248 100644 --- a/Streetwriters.Identity/Controllers/IdentityControllerBase.cs +++ b/Streetwriters.Identity/Controllers/IdentityControllerBase.cs @@ -36,12 +36,12 @@ namespace Streetwriters.Identity.Controllers protected UserManager UserManager { get; set; } protected SignInManager SignInManager { get; set; } protected RoleManager RoleManager { get; set; } - protected IEmailSender EmailSender { get; set; } + protected ITemplatedEmailSender EmailSender { get; set; } protected UrlEncoder UrlEncoder { get; set; } protected IMFAService MFAService { get; set; } public IdentityControllerBase( UserManager _userManager, - IEmailSender _emailSender, + ITemplatedEmailSender _emailSender, SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService diff --git a/Streetwriters.Identity/Controllers/MFAController.cs b/Streetwriters.Identity/Controllers/MFAController.cs index 1dee0e9..b942776 100644 --- a/Streetwriters.Identity/Controllers/MFAController.cs +++ b/Streetwriters.Identity/Controllers/MFAController.cs @@ -41,7 +41,7 @@ namespace Streetwriters.Identity.Controllers [Authorize(LocalApi.PolicyName)] public class MFAController : IdentityControllerBase { - public MFAController(UserManager _userManager, IEmailSender _emailSender, + public MFAController(UserManager _userManager, ITemplatedEmailSender _emailSender, SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) { } [HttpPost] diff --git a/Streetwriters.Identity/Controllers/SignupController.cs b/Streetwriters.Identity/Controllers/SignupController.cs index 60cd2f8..f944b9e 100644 --- a/Streetwriters.Identity/Controllers/SignupController.cs +++ b/Streetwriters.Identity/Controllers/SignupController.cs @@ -39,7 +39,7 @@ namespace Streetwriters.Identity.Controllers [Route("signup")] public class SignupController : IdentityControllerBase { - public SignupController(UserManager _userManager, IEmailSender _emailSender, + public SignupController(UserManager _userManager, ITemplatedEmailSender _emailSender, SignInManager _signInManager, RoleManager _roleManager, IMFAService _mfaService) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) { } diff --git a/Streetwriters.Identity/Interfaces/IEmailSender.cs b/Streetwriters.Identity/Interfaces/ITemplatedEmailSender.cs similarity index 94% rename from Streetwriters.Identity/Interfaces/IEmailSender.cs rename to Streetwriters.Identity/Interfaces/ITemplatedEmailSender.cs index e4ab014..32c9797 100644 --- a/Streetwriters.Identity/Interfaces/IEmailSender.cs +++ b/Streetwriters.Identity/Interfaces/ITemplatedEmailSender.cs @@ -1,33 +1,33 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System.Threading.Tasks; -using Streetwriters.Common.Interfaces; - -namespace Streetwriters.Identity.Interfaces -{ - public interface IEmailSender - { - Task SendConfirmationEmailAsync(string email, string callbackUrl, IClient client); - Task SendChangeEmailConfirmationAsync(string email, string code, IClient client); - Task SendPasswordResetEmailAsync(string email, string callbackUrl, IClient client); - Task Send2FACodeEmailAsync(string email, string code, IClient client); - Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client); - } -} \ No newline at end of file +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Threading.Tasks; +using Streetwriters.Common.Interfaces; + +namespace Streetwriters.Identity.Interfaces +{ + public interface ITemplatedEmailSender + { + Task SendConfirmationEmailAsync(string email, string callbackUrl, IClient client); + Task SendChangeEmailConfirmationAsync(string email, string code, IClient client); + Task SendPasswordResetEmailAsync(string email, string callbackUrl, IClient client); + Task Send2FACodeEmailAsync(string email, string code, IClient client); + Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client); + } +} diff --git a/Streetwriters.Identity/Models/EmailTemplate.cs b/Streetwriters.Identity/Models/EmailTemplate.cs deleted file mode 100644 index 84d86e9..0000000 --- a/Streetwriters.Identity/Models/EmailTemplate.cs +++ /dev/null @@ -1,33 +0,0 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using Streetwriters.Identity.Interfaces; - -namespace Streetwriters.Identity.Models -{ - public class EmailTemplate : IEmailTemplate - { - public int? Id { get; set; } - public object Data { get; set; } - public long? SendAt { get; set; } - public string Subject { get; set; } - public string Html { get; set; } - public string Text { get; set; } - } -} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/EmailSender.cs b/Streetwriters.Identity/Services/EmailSender.cs deleted file mode 100644 index 941dd38..0000000 --- a/Streetwriters.Identity/Services/EmailSender.cs +++ /dev/null @@ -1,276 +0,0 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System.Threading.Tasks; -using Streetwriters.Identity.Interfaces; -using SendGrid; -using SendGrid.Helpers.Mail; -using Streetwriters.Common; -using Streetwriters.Common.Interfaces; -using Streetwriters.Identity.Models; -using System; -using System.Net.Http; -using System.Text.Json; -using System.Net.Http.Headers; -using System.Text.Json.Serialization; -using MailKit.Net.Smtp; -using MailKit; -using MimeKit; -using System.IO; -using Scriban; -using WebMarkupMin.Core; -using WebMarkupMin.Core.Loggers; -using MimeKit.Cryptography; -using Org.BouncyCastle.Bcpg.OpenPgp; -using System.Linq; -using System.Threading; -using Org.BouncyCastle.Bcpg; -using System.Text; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace Streetwriters.Identity.Services -{ - public class EmailSender : IEmailSender, IAsyncDisposable - { - NNGnuPGContext NNGnuPGContext { get; set; } - SmtpClient mailClient; - public EmailSender(IConfiguration configuration) - { - NNGnuPGContext = new NNGnuPGContext(configuration.GetSection("PgpKeySettings")); - mailClient = new SmtpClient(); - } - - EmailTemplate Email2FATemplate = new EmailTemplate - { - Html = ReadMinifiedHtmlFile("Templates/Email2FACode.html"), - Text = File.ReadAllText("Templates/Email2FACode.txt"), - Subject = "Your {{app_name}} account 2FA code", - }; - - EmailTemplate ConfirmEmailTemplate = new EmailTemplate - { - Html = ReadMinifiedHtmlFile("Templates/ConfirmEmail.html"), - Text = File.ReadAllText("Templates/ConfirmEmail.txt"), - Subject = "Confirm your {{app_name}} account", - }; - - EmailTemplate ConfirmChangeEmailTemplate = new EmailTemplate - { - Html = ReadMinifiedHtmlFile("Templates/EmailChangeConfirmation.html"), - Text = File.ReadAllText("Templates/EmailChangeConfirmation.txt"), - Subject = "Change {{app_name}} account email address", - }; - - EmailTemplate PasswordResetEmailTemplate = new EmailTemplate - { - Html = ReadMinifiedHtmlFile("Templates/ResetAccountPassword.html"), - Text = File.ReadAllText("Templates/ResetAccountPassword.txt"), - Subject = "Reset {{app_name}} account password", - }; - - EmailTemplate FailedLoginAlertTemplate = new EmailTemplate - { - Html = ReadMinifiedHtmlFile("Templates/FailedLoginAlert.html"), - Text = File.ReadAllText("Templates/FailedLoginAlert.txt"), - Subject = "Failed login attempt on your {{app_name}} account", - }; - - public async Task Send2FACodeEmailAsync(string email, string code, IClient client) - { - var template = new EmailTemplate - { - Html = Email2FATemplate.Html, - Text = Email2FATemplate.Text, - Subject = Email2FATemplate.Subject, - Data = new - { - app_name = client.Name, - code = code - } - }; - await SendEmailAsync(email, template, client); - } - - public async Task SendConfirmationEmailAsync(string email, string callbackUrl, IClient client) - { - var template = new EmailTemplate - { - Html = ConfirmEmailTemplate.Html, - Text = ConfirmEmailTemplate.Text, - Subject = ConfirmEmailTemplate.Subject, - Data = new - { - app_name = client.Name, - confirm_link = callbackUrl - } - }; - await SendEmailAsync(email, template, client); - } - - public async Task SendChangeEmailConfirmationAsync(string email, string code, IClient client) - { - var template = new EmailTemplate - { - Html = ConfirmChangeEmailTemplate.Html, - Text = ConfirmChangeEmailTemplate.Text, - Subject = ConfirmChangeEmailTemplate.Subject, - Data = new - { - app_name = client.Name, - code = code - } - }; - await SendEmailAsync(email, template, client); - } - - public async Task SendPasswordResetEmailAsync(string email, string callbackUrl, IClient client) - { - var template = new EmailTemplate - { - Html = PasswordResetEmailTemplate.Html, - Text = PasswordResetEmailTemplate.Text, - Subject = PasswordResetEmailTemplate.Subject, - Data = new - { - app_name = client.Name, - reset_link = callbackUrl - } - }; - await SendEmailAsync(email, template, client); - } - - - public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client) - { - var template = new EmailTemplate - { - Html = FailedLoginAlertTemplate.Html, - Text = FailedLoginAlertTemplate.Text, - Subject = FailedLoginAlertTemplate.Subject, - Data = new - { - app_name = client.Name, - device_info = deviceInfo.Replace("\n", "
") - } - }; - await SendEmailAsync(email, template, client); - } - - private async Task SendEmailAsync(string email, IEmailTemplate template, IClient client) - { - if (!mailClient.IsConnected) - { - if (int.TryParse(Constants.SMTP_PORT, out int port)) - { - await mailClient.ConnectAsync(Constants.SMTP_HOST, port, MailKit.Security.SecureSocketOptions.Auto); - } - else - { - throw new InvalidDataException("SMTP_PORT is not a valid integer value."); - } - } - - if (!mailClient.IsAuthenticated) - await mailClient.AuthenticateAsync(Constants.SMTP_USERNAME, Constants.SMTP_PASSWORD); - - var message = new MimeMessage(); - var sender = new MailboxAddress(client.SenderName, client.SenderEmail); - message.From.Add(sender); - message.To.Add(new MailboxAddress("", email)); - message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data); - - if (!string.IsNullOrEmpty(Constants.SMTP_REPLYTO_EMAIL)) - message.ReplyTo.Add(MailboxAddress.Parse(Constants.SMTP_REPLYTO_EMAIL)); - - message.Body = await GetEmailBodyAsync(template, client, sender); - - await mailClient.SendAsync(message); - } - - private async Task GetEmailBodyAsync(IEmailTemplate template, IClient client, MailboxAddress sender) - { - var builder = new BodyBuilder(); - try - { - builder.TextBody = await Template.Parse(template.Text).RenderAsync(template.Data); - builder.HtmlBody = await Template.Parse(template.Html).RenderAsync(template.Data); - - var key = NNGnuPGContext.GetSigningKey(sender); - if (key != null) - { - using (MemoryStream outputStream = new MemoryStream()) - { - using (Stream armoredStream = new ArmoredOutputStream(outputStream)) - { - key.PublicKey.Encode(armoredStream); - } - outputStream.Seek(0, SeekOrigin.Begin); - builder.Attachments.Add($"{client.Id}_pub.asc", Encoding.ASCII.GetBytes(Encoding.ASCII.GetString(outputStream.ToArray()))); - } - return await MultipartSigned.CreateAsync(NNGnuPGContext, sender, DigestAlgorithm.Sha256, builder.ToMessageBody()); - } - else - { - return builder.ToMessageBody(); - } - } - catch (Exception ex) - { - await Slogger.Error("GetEmailBodyAsync", ex.ToString()); - return builder.ToMessageBody(); - } - } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - await mailClient.DisconnectAsync(true); - mailClient.Dispose(); - } - - - static string ReadMinifiedHtmlFile(string path) - { - var settings = new HtmlMinificationSettings() - { - WhitespaceMinificationMode = WhitespaceMinificationMode.Medium - }; - var cssMinifier = new KristensenCssMinifier(); - var jsMinifier = new CrockfordJsMinifier(); - - var minifier = new HtmlMinifier(settings, cssMinifier, jsMinifier, new NullLogger()); - - return minifier.Minify(File.ReadAllText(path), false).MinifiedContent; - } - } - - class NNGnuPGContext : GnuPGContext - { - IConfiguration PgpKeySettings { get; set; } - public NNGnuPGContext(IConfiguration pgpKeySettings) - { - PgpKeySettings = pgpKeySettings; - } - - protected override string GetPasswordForKey(PgpSecretKey key) - { - return PgpKeySettings[key.KeyId.ToString("X")]; - } - } -} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/MFAService.cs b/Streetwriters.Identity/Services/MFAService.cs index b37edda..b0ddeb4 100644 --- a/Streetwriters.Identity/Services/MFAService.cs +++ b/Streetwriters.Identity/Services/MFAService.cs @@ -39,9 +39,9 @@ namespace Streetwriters.Identity.Services const string SMS_ID_CLAIM = "mfa:sms:id"; private UserManager UserManager { get; set; } - private IEmailSender EmailSender { get; set; } + private ITemplatedEmailSender EmailSender { get; set; } private ISMSSender SMSSender { get; set; } - public MFAService(UserManager _userManager, IEmailSender emailSender, ISMSSender smsSender) + public MFAService(UserManager _userManager, ITemplatedEmailSender emailSender, ISMSSender smsSender) { UserManager = _userManager; EmailSender = emailSender; diff --git a/Streetwriters.Identity/Services/TemplatedEmailSender.cs b/Streetwriters.Identity/Services/TemplatedEmailSender.cs new file mode 100644 index 0000000..f7f5f96 --- /dev/null +++ b/Streetwriters.Identity/Services/TemplatedEmailSender.cs @@ -0,0 +1,184 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using MailKit; +using MailKit.Net.Smtp; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using MimeKit; +using MimeKit.Cryptography; +using Org.BouncyCastle.Bcpg; +using Org.BouncyCastle.Bcpg.OpenPgp; +using Scriban; +using SendGrid; +using SendGrid.Helpers.Mail; +using Streetwriters.Common; +using Streetwriters.Common.Helpers; +using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Models; +using Streetwriters.Identity.Interfaces; +using Streetwriters.Identity.Models; +using WebMarkupMin.Core; +using WebMarkupMin.Core.Loggers; + +namespace Streetwriters.Identity.Services +{ + public class TemplatedEmailSender : ITemplatedEmailSender + { + NNGnuPGContext NNGnuPGContext { get; set; } + IEmailSender EmailSender { get; set; } + + public TemplatedEmailSender(IConfiguration configuration, IEmailSender emailSender) + { + NNGnuPGContext = new NNGnuPGContext(configuration.GetSection("PgpKeySettings")); + EmailSender = emailSender; + } + + EmailTemplate Email2FATemplate = new EmailTemplate + { + Html = HtmlHelper.ReadMinifiedHtmlFile("Templates/Email2FACode.html"), + Text = File.ReadAllText("Templates/Email2FACode.txt"), + Subject = "Your {{app_name}} account 2FA code", + }; + + EmailTemplate ConfirmEmailTemplate = new EmailTemplate + { + Html = HtmlHelper.ReadMinifiedHtmlFile("Templates/ConfirmEmail.html"), + Text = File.ReadAllText("Templates/ConfirmEmail.txt"), + Subject = "Confirm your {{app_name}} account", + }; + + EmailTemplate ConfirmChangeEmailTemplate = new EmailTemplate + { + Html = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailChangeConfirmation.html"), + Text = File.ReadAllText("Templates/EmailChangeConfirmation.txt"), + Subject = "Change {{app_name}} account email address", + }; + + EmailTemplate PasswordResetEmailTemplate = new EmailTemplate + { + Html = HtmlHelper.ReadMinifiedHtmlFile("Templates/ResetAccountPassword.html"), + Text = File.ReadAllText("Templates/ResetAccountPassword.txt"), + Subject = "Reset {{app_name}} account password", + }; + + EmailTemplate FailedLoginAlertTemplate = new EmailTemplate + { + Html = HtmlHelper.ReadMinifiedHtmlFile("Templates/FailedLoginAlert.html"), + Text = File.ReadAllText("Templates/FailedLoginAlert.txt"), + Subject = "Failed login attempt on your {{app_name}} account", + }; + + public async Task Send2FACodeEmailAsync(string email, string code, IClient client) + { + var template = new EmailTemplate + { + Html = Email2FATemplate.Html, + Text = Email2FATemplate.Text, + Subject = Email2FATemplate.Subject, + Data = new { app_name = client.Name, code = code }, + }; + await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext); + } + + public async Task SendConfirmationEmailAsync( + string email, + string callbackUrl, + IClient client + ) + { + var template = new EmailTemplate + { + Html = ConfirmEmailTemplate.Html, + Text = ConfirmEmailTemplate.Text, + Subject = ConfirmEmailTemplate.Subject, + Data = new { app_name = client.Name, confirm_link = callbackUrl }, + }; + await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext); + } + + public async Task SendChangeEmailConfirmationAsync( + string email, + string code, + IClient client + ) + { + var template = new EmailTemplate + { + Html = ConfirmChangeEmailTemplate.Html, + Text = ConfirmChangeEmailTemplate.Text, + Subject = ConfirmChangeEmailTemplate.Subject, + Data = new { app_name = client.Name, code = code }, + }; + await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext); + } + + public async Task SendPasswordResetEmailAsync( + string email, + string callbackUrl, + IClient client + ) + { + var template = new EmailTemplate + { + Html = PasswordResetEmailTemplate.Html, + Text = PasswordResetEmailTemplate.Text, + Subject = PasswordResetEmailTemplate.Subject, + Data = new { app_name = client.Name, reset_link = callbackUrl }, + }; + await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext); + } + + public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client) + { + var template = new EmailTemplate + { + Html = FailedLoginAlertTemplate.Html, + Text = FailedLoginAlertTemplate.Text, + Subject = FailedLoginAlertTemplate.Subject, + Data = new + { + app_name = client.Name, + device_info = deviceInfo.Replace("\n", "
"), + }, + }; + await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext); + } + } + + public class NNGnuPGContext(IConfiguration pgpKeySettings) : GnuPGContext + { + IConfiguration PgpKeySettings { get; set; } = pgpKeySettings; + + protected override string GetPasswordForKey(PgpSecretKey key) + { + return PgpKeySettings[key.KeyId.ToString("X")]; + } + } +} diff --git a/Streetwriters.Identity/Startup.cs b/Streetwriters.Identity/Startup.cs index 5b3b2ac..fc32053 100644 --- a/Streetwriters.Identity/Startup.cs +++ b/Streetwriters.Identity/Startup.cs @@ -43,6 +43,7 @@ using Streetwriters.Common.Extensions; using Streetwriters.Common.Interfaces; using Streetwriters.Common.Messages; using Streetwriters.Common.Models; +using Streetwriters.Common.Services; using Streetwriters.Identity.Helpers; using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Jobs; @@ -69,6 +70,7 @@ namespace Streetwriters.Identity var connectionString = Constants.MONGODB_CONNECTION_STRING; services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient, Argon2PasswordHasher>(); diff --git a/Streetwriters.Identity/Streetwriters.Identity.csproj b/Streetwriters.Identity/Streetwriters.Identity.csproj index 5261bf7..58fedd7 100644 --- a/Streetwriters.Identity/Streetwriters.Identity.csproj +++ b/Streetwriters.Identity/Streetwriters.Identity.csproj @@ -8,12 +8,11 @@ - + - @@ -27,8 +26,6 @@ - - diff --git a/Streetwriters.Identity/Validation/MFAGrantValidator.cs b/Streetwriters.Identity/Validation/MFAGrantValidator.cs index 9d893f4..788f320 100644 --- a/Streetwriters.Identity/Validation/MFAGrantValidator.cs +++ b/Streetwriters.Identity/Validation/MFAGrantValidator.cs @@ -48,8 +48,8 @@ namespace Streetwriters.Identity.Validation private IHttpContextAccessor HttpContextAccessor { get; set; } private ITokenValidator TokenValidator { get; set; } private ITokenGenerationService TokenGenerationService { get; set; } - private IEmailSender EmailSender { get; set; } - public MFAGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITokenGenerationService tokenGenerationService, IEmailSender emailSender) + private ITemplatedEmailSender EmailSender { get; set; } + public MFAGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITokenGenerationService tokenGenerationService, ITemplatedEmailSender emailSender) { UserManager = userManager; SignInManager = signInManager; diff --git a/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs b/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs index ab8fcd9..9952954 100644 --- a/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs +++ b/Streetwriters.Identity/Validation/MFAPasswordGrantValidator.cs @@ -40,9 +40,9 @@ namespace Streetwriters.Identity.Validation private IMFAService MFAService { get; set; } private IHttpContextAccessor HttpContextAccessor { get; set; } private ITokenValidator TokenValidator { get; set; } - private IEmailSender EmailSender { get; set; } + private ITemplatedEmailSender EmailSender { get; set; } - public MFAPasswordGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, IEmailSender emailSender) + public MFAPasswordGrantValidator(UserManager userManager, SignInManager signInManager, IMFAService mfaService, IHttpContextAccessor httpContextAccessor, ITokenValidator tokenValidator, ITemplatedEmailSender emailSender) { UserManager = userManager; SignInManager = signInManager;