diff --git a/src/Identity/Extensions.Core/src/IEmailSender.cs b/src/Identity/Core/src/IEmailSender.cs similarity index 85% rename from src/Identity/Extensions.Core/src/IEmailSender.cs rename to src/Identity/Core/src/IEmailSender.cs index 614a1fd6254e..18e2959c9b39 100644 --- a/src/Identity/Extensions.Core/src/IEmailSender.cs +++ b/src/Identity/Core/src/IEmailSender.cs @@ -1,19 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity.UI.Services; /// /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose -/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails. +/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and password reset emails. /// public interface IEmailSender { /// /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose - /// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails. + /// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and apassword reset emails. /// /// The recipient's email address. /// The subject of the email. diff --git a/src/Identity/Core/src/IEmailSenderOfT.cs b/src/Identity/Core/src/IEmailSenderOfT.cs new file mode 100644 index 000000000000..d83735db7e6d --- /dev/null +++ b/src/Identity/Core/src/IEmailSenderOfT.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity; + +/// +/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose +/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation and password reset emails. +/// +public interface IEmailSender where TUser : class +{ + /// + /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose + /// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails. + /// + /// The user that is attempting to confirm their email. + /// The recipient's email address. + /// The link to follow to confirm a user's email. Do not double encode this. + /// + Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink); + + /// + /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose + /// email abstraction. It should be implemented by the application so the Identity infrastructure can send password reset emails. + /// + /// The user that is attempting to reset their password. + /// The recipient's email address. + /// The link to follow to reset the user password. Do not double encode this. + /// + Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink); + + /// + /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose + /// email abstraction. It should be implemented by the application so the Identity infrastructure can send password reset emails. + /// + /// The user that is attempting to reset their password. + /// The recipient's email address. + /// The code to use to reset the user password. Do not double encode this. + /// + Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode); +} diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index 00a7022ae43c..8a9ed5ae1431 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.Data; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -45,7 +44,7 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou var timeProvider = endpoints.ServiceProvider.GetRequiredService(); var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService>(); - var emailSender = endpoints.ServiceProvider.GetRequiredService(); + var emailSender = endpoints.ServiceProvider.GetRequiredService>(); var linkGenerator = endpoints.ServiceProvider.GetRequiredService(); // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation. @@ -189,7 +188,6 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText; confirmEmailEndpointName = $"{nameof(MapIdentityApi)}-{finalPattern}"; endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName)); - endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName)); }); routeGroup.MapPost("/resendConfirmationEmail", async Task @@ -216,8 +214,7 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T var code = await userManager.GeneratePasswordResetTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", - $"Reset your password using the following code: {HtmlEncoder.Default.Encode(code)}"); + await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code)); } // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have @@ -416,8 +413,7 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager userManager var confirmEmailUrl = linkGenerator.GetUriByName(context, confirmEmailEndpointName, routeValues) ?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); - await emailSender.SendEmailAsync(email, "Confirm your email", - $"Please confirm your account by clicking here."); + await emailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(confirmEmailUrl)); } return new IdentityEndpointsConventionBuilder(routeGroup); diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index e51c6ee31ef2..eec4e9d04ede 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -97,6 +97,7 @@ public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder) builder.AddSignInManager(); builder.AddDefaultTokenProviders(); + builder.Services.TryAddTransient(typeof(IEmailSender<>), typeof(DefaultMessageEmailSender<>)); builder.Services.TryAddTransient(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, IdentityEndpointsJsonOptionsSetup>()); return builder; diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index 1488733572e1..991c08e76922 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Identity/Extensions.Core/src/NoOpEmailSender.cs b/src/Identity/Core/src/NoOpEmailSender.cs similarity index 96% rename from src/Identity/Extensions.Core/src/NoOpEmailSender.cs rename to src/Identity/Core/src/NoOpEmailSender.cs index aadc3dd502a6..6ad0b62ea648 100644 --- a/src/Identity/Extensions.Core/src/NoOpEmailSender.cs +++ b/src/Identity/Core/src/NoOpEmailSender.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Threading.Tasks; - namespace Microsoft.AspNetCore.Identity.UI.Services; /// diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 2807929dcfd1..8b9f4af02588 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -75,6 +75,10 @@ Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.RecoveryCodesLeft.init -> v Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.SharedKey.get -> string! Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.SharedKey.init -> void Microsoft.AspNetCore.Identity.Data.TwoFactorResponse.TwoFactorResponse() -> void +Microsoft.AspNetCore.Identity.IEmailSender +Microsoft.AspNetCore.Identity.IEmailSender.SendConfirmationLinkAsync(TUser! user, string! email, string! confirmationLink) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IEmailSender.SendPasswordResetCodeAsync(TUser! user, string! email, string! resetCode) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.IEmailSender.SendPasswordResetLinkAsync(TUser! user, string! email, string! resetLink) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.SecurityStampValidator.SecurityStampValidator(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.SignInManager! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void Microsoft.AspNetCore.Identity.SecurityStampValidator.TimeProvider.get -> System.TimeProvider! Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.get -> System.TimeProvider? @@ -82,6 +86,11 @@ Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.set -> Microsoft.AspNetCore.Identity.SignInManager.AuthenticationScheme.get -> string! Microsoft.AspNetCore.Identity.SignInManager.AuthenticationScheme.set -> void Microsoft.AspNetCore.Identity.TwoFactorSecurityStampValidator.TwoFactorSecurityStampValidator(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.SignInManager! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.NoOpEmailSender() -> void +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions static Microsoft.AspNetCore.Identity.IdentityBuilderExtensions.AddApiEndpoints(this Microsoft.AspNetCore.Identity.IdentityBuilder! builder) -> Microsoft.AspNetCore.Identity.IdentityBuilder! static Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index 33bb8e55c6e3..d9173be85cd0 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -2,11 +2,6 @@ Microsoft.AspNetCore.Identity.IdentitySchemaVersions Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.get -> System.Version! Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.set -> void -Microsoft.AspNetCore.Identity.UI.Services.IEmailSender -Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender -Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.NoOpEmailSender() -> void -Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Default -> System.Version! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version1 -> System.Version! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version2 -> System.Version! diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs index c7ee2f10179f..30eac04d72c8 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ExternalLogin.cshtml.cs @@ -7,7 +7,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -95,7 +94,7 @@ internal sealed class ExternalLoginModel : ExternalLoginModel where TUser private readonly UserManager _userManager; private readonly IUserStore _userStore; private readonly IUserEmailStore _emailStore; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; private readonly ILogger _logger; public ExternalLoginModel( @@ -103,7 +102,7 @@ public ExternalLoginModel( UserManager userManager, IUserStore userStore, ILogger logger, - IEmailSender emailSender) + IEmailSender emailSender) { _signInManager = signInManager; _userManager = userManager; @@ -206,8 +205,7 @@ public override async Task OnPostConfirmationAsync(string? return values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); // If account confirmation is required, we need to show the link if we don't have a real email sender if (_userManager.Options.SignIn.RequireConfirmedAccount) diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ForgotPassword.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ForgotPassword.cshtml.cs index 2a30467aa485..9ef8778f352a 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ForgotPassword.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ForgotPassword.cshtml.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -52,9 +51,9 @@ public class InputModel internal sealed class ForgotPasswordModel : ForgotPasswordModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; - public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) + public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -81,10 +80,7 @@ public override async Task OnPostAsync() values: new { area = "Identity", code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.Email, - "Reset Password", - $"Please reset your password by clicking here."); + await _emailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); return RedirectToPage("./ForgotPasswordConfirmation"); } diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Manage/Email.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Manage/Email.cshtml.cs index d061d219c465..163feb1f00ba 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Manage/Email.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Manage/Email.cshtml.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -83,12 +82,12 @@ internal sealed class EmailModel : EmailModel where TUser : class { private readonly UserManager _userManager; private readonly SignInManager _signInManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; public EmailModel( UserManager userManager, SignInManager signInManager, - IEmailSender emailSender) + IEmailSender emailSender) { _userManager = userManager; _signInManager = signInManager; @@ -145,10 +144,7 @@ public override async Task OnPostChangeEmailAsync() pageHandler: null, values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.NewEmail, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); StatusMessage = "Confirmation link to change email sent. Please check your email."; return RedirectToPage(); @@ -181,10 +177,7 @@ public override async Task OnPostSendVerificationEmailAsync() pageHandler: null, values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme); - await _emailSender.SendEmailAsync( - email!, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, email!, HtmlEncoder.Default.Encode(callbackUrl!)); StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Register.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Register.cshtml.cs index c783a8be7927..c8390ae7c62f 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Register.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Register.cshtml.cs @@ -8,7 +8,6 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -98,14 +97,14 @@ internal sealed class RegisterModel : RegisterModel where TUser : class private readonly IUserStore _userStore; private readonly IUserEmailStore _emailStore; private readonly ILogger _logger; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; public RegisterModel( UserManager userManager, IUserStore userStore, SignInManager signInManager, ILogger logger, - IEmailSender emailSender) + IEmailSender emailSender) { _userManager = userManager; _userStore = userStore; @@ -146,8 +145,7 @@ public override async Task OnPostAsync(string? returnUrl = null) values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); if (_userManager.Options.SignIn.RequireConfirmedAccount) { diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs index 0423e2820fee..4af237c896e2 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs @@ -3,7 +3,6 @@ using System.Text; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -46,9 +45,9 @@ public class RegisterConfirmationModel : PageModel internal sealed class RegisterConfirmationModel : RegisterConfirmationModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _sender; + private readonly IEmailSender _sender; - public RegisterConfirmationModel(UserManager userManager, IEmailSender sender) + public RegisterConfirmationModel(UserManager userManager, IEmailSender sender) { _userManager = userManager; _sender = sender; @@ -70,7 +69,7 @@ public override async Task OnGetAsync(string email, string? retur Email = email; // If the email sender is a no-op, display the confirm link in the page - DisplayConfirmAccountLink = _sender is NoOpEmailSender; + DisplayConfirmAccountLink = _sender is DefaultMessageEmailSender defaultMessageSender && defaultMessageSender.IsNoOp; if (DisplayConfirmAccountLink) { var userId = await _userManager.GetUserIdAsync(user); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml.cs index f6e3893fbcb4..c0d18466ceae 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -58,9 +57,9 @@ public class InputModel internal sealed class ResendEmailConfirmationModel : ResendEmailConfirmationModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; - public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) + public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -92,10 +91,7 @@ public override async Task OnPostAsync() pageHandler: null, values: new { userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.Email, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); return Page(); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs index 3187a4756a69..0eccb938ec26 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ExternalLogin.cshtml.cs @@ -7,7 +7,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -95,7 +94,7 @@ internal sealed class ExternalLoginModel : ExternalLoginModel where TUser private readonly UserManager _userManager; private readonly IUserStore _userStore; private readonly IUserEmailStore _emailStore; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; private readonly ILogger _logger; public ExternalLoginModel( @@ -103,7 +102,7 @@ public ExternalLoginModel( UserManager userManager, IUserStore userStore, ILogger logger, - IEmailSender emailSender) + IEmailSender emailSender) { _signInManager = signInManager; _userManager = userManager; @@ -206,8 +205,7 @@ public override async Task OnPostConfirmationAsync(string? return values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); // If account confirmation is required, we need to show the link if we don't have a real email sender if (_userManager.Options.SignIn.RequireConfirmedAccount) diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ForgotPassword.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ForgotPassword.cshtml.cs index ec8dc6fed965..6fea58f32175 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ForgotPassword.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ForgotPassword.cshtml.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -52,9 +51,9 @@ public class InputModel internal sealed class ForgotPasswordModel : ForgotPasswordModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; - public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) + public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -81,10 +80,7 @@ public override async Task OnPostAsync() values: new { area = "Identity", code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.Email, - "Reset Password", - $"Please reset your password by clicking here."); + await _emailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); return RedirectToPage("./ForgotPasswordConfirmation"); } diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Manage/Email.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Manage/Email.cshtml.cs index c56438d0efe3..54bd9655fd96 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Manage/Email.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Manage/Email.cshtml.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -83,12 +82,12 @@ internal sealed class EmailModel : EmailModel where TUser : class { private readonly UserManager _userManager; private readonly SignInManager _signInManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; public EmailModel( UserManager userManager, SignInManager signInManager, - IEmailSender emailSender) + IEmailSender emailSender) { _userManager = userManager; _signInManager = signInManager; @@ -145,10 +144,7 @@ public override async Task OnPostChangeEmailAsync() pageHandler: null, values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.NewEmail, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); StatusMessage = "Confirmation link to change email sent. Please check your email."; return RedirectToPage(); @@ -181,10 +177,7 @@ public override async Task OnPostSendVerificationEmailAsync() pageHandler: null, values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - email!, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, email!, HtmlEncoder.Default.Encode(callbackUrl)); StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Register.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Register.cshtml.cs index daa94280f2d7..b55c9eba5167 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Register.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/Register.cshtml.cs @@ -8,7 +8,6 @@ using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -98,14 +97,14 @@ internal sealed class RegisterModel : RegisterModel where TUser : class private readonly IUserStore _userStore; private readonly IUserEmailStore _emailStore; private readonly ILogger _logger; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; public RegisterModel( UserManager userManager, IUserStore userStore, SignInManager signInManager, ILogger logger, - IEmailSender emailSender) + IEmailSender emailSender) { _userManager = userManager; _userStore = userStore; @@ -146,8 +145,7 @@ public override async Task OnPostAsync(string? returnUrl = null) values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); if (_userManager.Options.SignIn.RequireConfirmedAccount) { diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs index 51a090db6f82..3003bb7f8d6e 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs @@ -3,7 +3,6 @@ using System.Text; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -46,9 +45,9 @@ public class RegisterConfirmationModel : PageModel internal sealed class RegisterConfirmationModel : RegisterConfirmationModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _sender; + private readonly IEmailSender _sender; - public RegisterConfirmationModel(UserManager userManager, IEmailSender sender) + public RegisterConfirmationModel(UserManager userManager, IEmailSender sender) { _userManager = userManager; _sender = sender; @@ -70,7 +69,7 @@ public override async Task OnGetAsync(string email, string? retur Email = email; // If the email sender is a no-op, display the confirm link in the page - DisplayConfirmAccountLink = _sender is NoOpEmailSender; + DisplayConfirmAccountLink = _sender is DefaultMessageEmailSender defaultMessageSender && defaultMessageSender.IsNoOp; if (DisplayConfirmAccountLink) { var userId = await _userManager.GetUserIdAsync(user); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ResendEmailConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ResendEmailConfirmation.cshtml.cs index b31525eb41e5..ba88ab597c83 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ResendEmailConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/ResendEmailConfirmation.cshtml.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; @@ -58,9 +57,9 @@ public class InputModel internal sealed class ResendEmailConfirmationModel : ResendEmailConfirmationModel where TUser : class { private readonly UserManager _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; - public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) + public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -92,10 +91,7 @@ public override async Task OnPostAsync() pageHandler: null, values: new { userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.Email, - "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); return Page(); diff --git a/src/Identity/UI/src/IdentityBuilderUIExtensions.cs b/src/Identity/UI/src/IdentityBuilderUIExtensions.cs index 014936cbbabe..ff7adbc2203d 100644 --- a/src/Identity/UI/src/IdentityBuilderUIExtensions.cs +++ b/src/Identity/UI/src/IdentityBuilderUIExtensions.cs @@ -60,6 +60,7 @@ public static IdentityBuilder AddDefaultUI(this IdentityBuilder builder) typeof(IdentityDefaultUIConfigureOptions<>) .MakeGenericType(builder.UserType)); builder.Services.TryAddTransient(); + builder.Services.TryAddTransient(typeof(IEmailSender<>), typeof(DefaultMessageEmailSender<>)); return builder; } diff --git a/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj index 986c84729a81..135ff8d97735 100644 --- a/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj +++ b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj @@ -20,6 +20,10 @@ GetIdentityUIAssets + + + + diff --git a/src/Identity/UI/src/PublicAPI.Unshipped.txt b/src/Identity/UI/src/PublicAPI.Unshipped.txt index 4a3b9f9670bb..cbdf5a233663 100644 --- a/src/Identity/UI/src/PublicAPI.Unshipped.txt +++ b/src/Identity/UI/src/PublicAPI.Unshipped.txt @@ -1,5 +1,5 @@ #nullable enable *REMOVED*Microsoft.AspNetCore.Identity.UI.Services.IEmailSender *REMOVED*Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! -Microsoft.AspNetCore.Identity.UI.Services.IEmailSender (forwarded, contained in Microsoft.Extensions.Identity.Core) -Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! (forwarded, contained in Microsoft.Extensions.Identity.Core) +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender (forwarded, contained in Microsoft.AspNetCore.Identity) +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! (forwarded, contained in Microsoft.AspNetCore.Identity) diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Register.cshtml.cs b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Register.cshtml.cs index 8e0cb0599a27..4a9806e417ef 100644 --- a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -17,13 +17,13 @@ public class RegisterModel : PageModel private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly IEmailSender _emailSender; + private readonly IEmailSender _emailSender; public RegisterModel( UserManager userManager, SignInManager signInManager, ILogger logger, - IEmailSender emailSender) + IEmailSender emailSender) { _userManager = userManager; _signInManager = signInManager; @@ -99,8 +99,7 @@ public async Task OnPostAsync(string returnUrl = null) values: new { userId = user.Id, code = code }, protocol: Request.Scheme); - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); if (_userManager.Options.SignIn.RequireConfirmedAccount) { diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index bf74b839ba32..57ac19d7fccf 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -562,6 +562,27 @@ public async Task EmailConfirmationCanBeResent() AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password })); } + [Fact] + public async Task AccountConfirmationEmailCanBeCustomized() + { + var emailSender = new TestEmailSender(); + var customEmailSender = new TestCustomEmailSender(emailSender); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton>(customEmailSender); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var email = Assert.Single(emailSender.Emails); + Assert.Equal(Email, email.Address); + Assert.Equal(TestCustomEmailSender.CustomSubject, email.Subject); + Assert.Equal(TestCustomEmailSender.CustomMessage, email.HtmlMessage); + } + [Fact] public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType() { @@ -1509,5 +1530,24 @@ public Task SendEmailAsync(string email, string subject, string htmlMessage) } } + private sealed class TestCustomEmailSender(IEmailSender emailSender) : IEmailSender + { + public const string CustomSubject = "Custom subject"; + public const string CustomMessage = "Custom message"; + + public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) + { + Assert.Equal(user.Email, email); + emailSender.SendEmailAsync(email, "Custom subject", "Custom message"); + return Task.CompletedTask; + } + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => + throw new NotImplementedException(); + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => + throw new NotImplementedException(); + } + private sealed record TestEmail(string Address, string Subject, string HtmlMessage); } diff --git a/src/Shared/DefaultMessageEmailSender.cs b/src/Shared/DefaultMessageEmailSender.cs new file mode 100644 index 000000000000..9c19065ceaf2 --- /dev/null +++ b/src/Shared/DefaultMessageEmailSender.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Identity.UI.Services; + +namespace Microsoft.AspNetCore.Identity; + +internal sealed class DefaultMessageEmailSender(IEmailSender emailSender) : IEmailSender where TUser : class +{ + internal bool IsNoOp => emailSender is NoOpEmailSender; + + public Task SendConfirmationLinkAsync(TUser user, string email, string confirmationLink) => + emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); + + public Task SendPasswordResetLinkAsync(TUser user, string email, string resetLink) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); + + public Task SendPasswordResetCodeAsync(TUser user, string email, string resetCode) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); +}