diff --git a/src/Identity/UI/ref/Microsoft.AspNetCore.Identity.UI.netcoreapp.cs b/src/Identity/UI/ref/Microsoft.AspNetCore.Identity.UI.netcoreapp.cs index 7208585d3aae..4f25f3b9fab4 100644 --- a/src/Identity/UI/ref/Microsoft.AspNetCore.Identity.UI.netcoreapp.cs +++ b/src/Identity/UI/ref/Microsoft.AspNetCore.Identity.UI.netcoreapp.cs @@ -207,6 +207,22 @@ public InputModel() { } } } [Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute] + public abstract partial class ResendEmailConfirmationModel : Microsoft.AspNetCore.Mvc.RazorPages.PageModel + { + protected ResendEmailConfirmationModel() { } + [Microsoft.AspNetCore.Mvc.BindPropertyAttribute] + public Microsoft.AspNetCore.Identity.UI.V3.Pages.Account.Internal.ResendEmailConfirmationModel.InputModel Input { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public virtual void OnGet() { } + public virtual System.Threading.Tasks.Task OnPostAsync() { throw null; } + public partial class InputModel + { + public InputModel() { } + [System.ComponentModel.DataAnnotations.EmailAddressAttribute] + [System.ComponentModel.DataAnnotations.RequiredAttribute] + public string Email { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + } + [Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute] public partial class ResetPasswordConfirmationModel : Microsoft.AspNetCore.Mvc.RazorPages.PageModel { public ResetPasswordConfirmationModel() { } @@ -657,6 +673,22 @@ public InputModel() { } } } [Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute] + public abstract partial class ResendEmailConfirmationModel : Microsoft.AspNetCore.Mvc.RazorPages.PageModel + { + protected ResendEmailConfirmationModel() { } + [Microsoft.AspNetCore.Mvc.BindPropertyAttribute] + public Microsoft.AspNetCore.Identity.UI.V4.Pages.Account.Internal.ResendEmailConfirmationModel.InputModel Input { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public virtual void OnGet() { } + public virtual System.Threading.Tasks.Task OnPostAsync() { throw null; } + public partial class InputModel + { + public InputModel() { } + [System.ComponentModel.DataAnnotations.EmailAddressAttribute] + [System.ComponentModel.DataAnnotations.RequiredAttribute] + public string Email { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + } + [Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute] public partial class ResetPasswordConfirmationModel : Microsoft.AspNetCore.Mvc.RazorPages.PageModel { public ResetPasswordConfirmationModel() { } diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/Login.cshtml b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/Login.cshtml index 9b17733fbc6d..8f4559fb78e3 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/Login.cshtml +++ b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/Login.cshtml @@ -41,6 +41,9 @@

Register as a new user

+

+ Resend email confirmation +

diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml new file mode 100644 index 000000000000..910891fb4682 --- /dev/null +++ b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml @@ -0,0 +1,26 @@ +@page +@model ResendEmailConfirmationModel +@{ + ViewData["Title"] = "Resend email confirmation"; +} + +

@ViewData["Title"]

+

Enter your email.

+
+
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml.cs new file mode 100644 index 000000000000..d1d2ef784698 --- /dev/null +++ b/src/Identity/UI/src/Areas/Identity/Pages/V3/Account/ResendEmailConfirmation.cshtml.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Microsoft.AspNetCore.Identity.UI.V3.Pages.Account.Internal +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [AllowAnonymous] + [IdentityDefaultUI(typeof(ResendEmailConfirmationModel<>))] + public abstract class ResendEmailConfirmationModel : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public virtual void OnGet() => throw new NotImplementedException(); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public virtual Task OnPostAsync() => throw new NotImplementedException(); + } + + internal class ResendEmailConfirmationModel : ResendEmailConfirmationModel where TUser : class + { + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + public override void OnGet() + { + } + + public override async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.FindByEmailAsync(Input.Email); + if (user == null) + { + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + + var userId = await _userManager.GetUserIdAsync(user); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + 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."); + + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + } +} diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Login.cshtml b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Login.cshtml index 7db27382777b..47b87509561e 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Login.cshtml +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/Login.cshtml @@ -41,6 +41,9 @@

Register as a new user

+

+ Resend email confirmation +

diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml new file mode 100644 index 000000000000..16d11e9d8ebd --- /dev/null +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml @@ -0,0 +1,26 @@ +@page +@model ResendEmailConfirmationModel +@{ + ViewData["Title"] = "Resend email confirmation"; +} + +

@ViewData["Title"]

+

Enter your email.

+
+
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} 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 new file mode 100644 index 000000000000..559ce47bc525 --- /dev/null +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/ResendEmailConfirmation.cshtml.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Microsoft.AspNetCore.Identity.UI.V4.Pages.Account.Internal +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [AllowAnonymous] + [IdentityDefaultUI(typeof(ResendEmailConfirmationModel<>))] + public abstract class ResendEmailConfirmationModel : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public virtual void OnGet() => throw new NotImplementedException(); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public virtual Task OnPostAsync() => throw new NotImplementedException(); + } + + internal class ResendEmailConfirmationModel : ResendEmailConfirmationModel where TUser : class + { + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + public override void OnGet() + { + } + + public override async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.FindByEmailAsync(Input.Email); + if (user == null) + { + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + + var userId = await _userManager.GetUserIdAsync(user); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + 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."); + + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + } +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/web.config b/src/Identity/samples/IdentitySample.DefaultUI/web.config index f7ac679334bc..6d25118e27e6 100644 --- a/src/Identity/samples/IdentitySample.DefaultUI/web.config +++ b/src/Identity/samples/IdentitySample.DefaultUI/web.config @@ -2,8 +2,12 @@ - + - + + + + + \ No newline at end of file diff --git a/src/Identity/test/Identity.FunctionalTests/LoginTests.cs b/src/Identity/test/Identity.FunctionalTests/LoginTests.cs index 8a5125495ab2..bb7529a3a6c2 100644 --- a/src/Identity/test/Identity.FunctionalTests/LoginTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/LoginTests.cs @@ -230,6 +230,33 @@ void ConfigureTestServices(IServiceCollection services) => services await UserStories.LoginExistingUserAsync(newClient, userName, password); } + [Fact] + public async Task CanResendConfirmingEmail() + { + // Arrange + var emailSender = new ContosoEmailSender(); + void ConfigureTestServices(IServiceCollection services) => services + .SetupTestEmailSender(emailSender) + .SetupEmailRequired(); + + var server = ServerFactory.WithWebHostBuilder(whb => whb.ConfigureServices(ConfigureTestServices)); + + var client = server.CreateClient(); + var newClient = server.CreateClient(); + + var userName = $"{Guid.NewGuid()}@example.com"; + var password = $"!Test.Password1$"; + + var loggedIn = await UserStories.RegisterNewUserAsync(client, userName, password); + + // Act & Assert + // Use a new client to simulate a new browser session. + await UserStories.ResendConfirmEmailAsync(server.CreateClient(), userName); + Assert.Equal(2, emailSender.SentEmails.Count); + var email = emailSender.SentEmails.Last(); + await UserStories.ConfirmEmailAsync(email, newClient); + } + [Fact] public async Task CanLogInAfterConfirmingEmail_WithGlobalAuthorizeFilter() { diff --git a/src/Identity/test/Identity.FunctionalTests/Pages/Account/Login.cs b/src/Identity/test/Identity.FunctionalTests/Pages/Account/Login.cs index 7db145000fa5..6960ac89645f 100644 --- a/src/Identity/test/Identity.FunctionalTests/Pages/Account/Login.cs +++ b/src/Identity/test/Identity.FunctionalTests/Pages/Account/Login.cs @@ -13,6 +13,7 @@ public class Login : DefaultUIPage { private readonly IHtmlFormElement _loginForm; private readonly IHtmlAnchorElement _forgotPasswordLink; + private readonly IHtmlAnchorElement _reconfirmLink; private readonly IHtmlFormElement _externalLoginForm; private readonly IHtmlElement _contosoButton; private readonly IHtmlElement _loginButton; @@ -26,6 +27,7 @@ public Login( _loginForm = HtmlAssert.HasForm("#account", login); _loginButton = HtmlAssert.HasElement("#login-submit", login); _forgotPasswordLink = HtmlAssert.HasLink("#forgot-password", login); + _reconfirmLink = HtmlAssert.HasLink("#resend-confirmation", login); if (Context.ContosoLoginEnabled) { _externalLoginForm = HtmlAssert.HasForm("#external-account", login); @@ -52,6 +54,14 @@ public async Task ClickForgotPasswordLinkAsync() return new ForgotPassword(Client, forgotPassword, Context); } + public async Task ClickReconfirmEmailLinkAsync() + { + var response = await Client.GetAsync(_reconfirmLink.Href); + var forgotPassword = await ResponseAssert.IsHtmlDocumentAsync(response); + + return new ResendEmailConfirmation(Client, forgotPassword, Context); + } + public async Task LoginValidUserAsync(string userName, string password) { var loggedIn = await SendLoginForm(userName, password); diff --git a/src/Identity/test/Identity.FunctionalTests/Pages/Account/ResendEmailConfirmation.cs b/src/Identity/test/Identity.FunctionalTests/Pages/Account/ResendEmailConfirmation.cs new file mode 100644 index 000000000000..d41cb142d8b0 --- /dev/null +++ b/src/Identity/test/Identity.FunctionalTests/Pages/Account/ResendEmailConfirmation.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AngleSharp.Dom.Html; + +namespace Microsoft.AspNetCore.Identity.FunctionalTests.Account +{ + public class ResendEmailConfirmation : DefaultUIPage + { + private readonly IHtmlFormElement _resendForm; + + public ResendEmailConfirmation(HttpClient client, IHtmlDocument document, DefaultUIContext context) : base(client, document, context) + { + _resendForm = HtmlAssert.HasForm(document); + } + + public Task ResendAsync(string email) + => Client.SendAsync(_resendForm, new Dictionary + { + ["Input_Email"] = email + }); + } +} diff --git a/src/Identity/test/Identity.FunctionalTests/UserStories.cs b/src/Identity/test/Identity.FunctionalTests/UserStories.cs index 2cc1cde82275..2610139fe053 100644 --- a/src/Identity/test/Identity.FunctionalTests/UserStories.cs +++ b/src/Identity/test/Identity.FunctionalTests/UserStories.cs @@ -194,6 +194,16 @@ internal static async Task ConfirmEmailAsync(IdentityEmail email, .WithConfirmedEmail()); } + internal static async Task ResendConfirmEmailAsync(HttpClient client, string email) + { + var index = await Index.CreateAsync(client); + var login = await index.ClickLoginLinkAsync(); + var reconfirm = await login.ClickReconfirmEmailLinkAsync(); + var response = await reconfirm.ResendAsync(email); + ResponseAssert.IsOK(response); + Assert.Contains("Verification email sent.", await response.Content.ReadAsStringAsync()); + } + internal static async Task ForgotPasswordAsync(HttpClient client, string userName) { var index = await Index.CreateAsync(client); diff --git a/src/ProjectTemplates/test/Helpers/PageUrls.cs b/src/ProjectTemplates/test/Helpers/PageUrls.cs index afb0783cbc4d..deb12fb16c65 100644 --- a/src/ProjectTemplates/test/Helpers/PageUrls.cs +++ b/src/ProjectTemplates/test/Helpers/PageUrls.cs @@ -12,6 +12,7 @@ public static class PageUrls public const string LoginUrl = "/Identity/Account/Login"; public const string RegisterUrl = "/Identity/Account/Register"; public const string ForgotPassword = "/Identity/Account/ForgotPassword"; + public const string ResendEmailConfirmation = "/Identity/Account/ResendEmailConfirmation"; public const string ExternalArticle = "https://go.microsoft.com/fwlink/?LinkID=532715"; } } diff --git a/src/ProjectTemplates/test/MvcTemplateTest.cs b/src/ProjectTemplates/test/MvcTemplateTest.cs index 76566b2d3f44..99476475b480 100644 --- a/src/ProjectTemplates/test/MvcTemplateTest.cs +++ b/src/ProjectTemplates/test/MvcTemplateTest.cs @@ -182,6 +182,7 @@ public async Task MvcTemplate_IndividualAuth(bool useLocalDB) PageUrls.PrivacyUrl, PageUrls.ForgotPassword, PageUrls.RegisterUrl, + PageUrls.ResendEmailConfirmation, PageUrls.ExternalArticle, PageUrls.PrivacyUrl } }, diff --git a/src/ProjectTemplates/test/RazorPagesTemplateTest.cs b/src/ProjectTemplates/test/RazorPagesTemplateTest.cs index 7cd41d56b26e..ad7c967d48d6 100644 --- a/src/ProjectTemplates/test/RazorPagesTemplateTest.cs +++ b/src/ProjectTemplates/test/RazorPagesTemplateTest.cs @@ -172,6 +172,7 @@ public async Task RazorPagesTemplate_IndividualAuth(bool useLocalDB) PageUrls.PrivacyUrl, PageUrls.ForgotPassword, PageUrls.RegisterUrl, + PageUrls.ResendEmailConfirmation, PageUrls.ExternalArticle, PageUrls.PrivacyUrl } },