From 3d46c80cff18066bbaa651c19c332513276c5464 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Mon, 23 Oct 2017 21:58:21 -0400 Subject: [PATCH] Adding 2FA Authenticator Support (#66) * Adding support for 2fa, more auth options * WIP getting auth stuff working * Added Manage views. 2FA working now for MVC app. * Switching to using a controller for no-UI logout scenario * Adding Razor Pages impl of 2FA auth stuff. Works. --- .../Interfaces/IEmailSender.cs | 10 + .../Identity/ApplicationUser.cs | 1 - src/Infrastructure/Infrastructure.csproj | 1 - src/Infrastructure/Services/EmailSender.cs | 16 + src/Web/Controllers/AccountController.cs | 116 +++- src/Web/Controllers/ManageController.cs | 499 ++++++++++++++++++ src/Web/Extensions/EmailSenderExtensions.cs | 15 + src/Web/Extensions/UrlHelperExtensions.cs | 25 + src/Web/Startup.cs | 2 + .../{ => Account}/LoginViewModel.cs | 2 +- .../Account/LoginWith2faViewModel.cs | 18 + .../{ => Account}/RegisterViewModel.cs | 2 +- .../Account/ResetPasswordViewModel.cs | 23 + .../Manage/ChangePasswordViewModel.cs | 25 + .../Manage/EnableAuthenticatorViewModel.cs | 19 + .../Manage/ExternalLoginsViewModel.cs | 14 + .../Manage/GenerateRecoveryCodesViewModel.cs | 7 + src/Web/ViewModels/Manage/IndexViewModel.cs | 21 + .../ViewModels/Manage/RemoveLoginViewModel.cs | 8 + .../ViewModels/Manage/SetPasswordViewModel.cs | 20 + .../TwoFactorAuthenticationViewModel.cs | 11 + src/Web/Views/Account/Lockout.cshtml | 8 + src/Web/Views/Account/LoginWith2fa.cshtml | 40 ++ src/Web/Views/Account/Signin.cshtml | 6 +- src/Web/Views/Manage/ChangePassword.cshtml | 35 ++ src/Web/Views/Manage/Disable2fa.cshtml | 24 + .../Views/Manage/EnableAuthenticator.cshtml | 52 ++ src/Web/Views/Manage/ExternalLogins.cshtml | 52 ++ .../Views/Manage/GenerateRecoveryCodes.cshtml | 24 + src/Web/Views/Manage/Index.cshtml | 45 ++ src/Web/Views/Manage/ManageNavPages.cs | 35 ++ .../Views/Manage/ResetAuthenticator.cshtml | 21 + src/Web/Views/Manage/SetPassword.cshtml | 34 ++ .../Manage/TwoFactorAuthentication.cshtml | 49 ++ src/Web/Views/Manage/_Layout.cshtml | 23 + src/Web/Views/Manage/_ManageNav.cshtml | 15 + src/Web/Views/Manage/_StatusMessage.cshtml | 10 + src/Web/Views/Manage/_ViewImports.cshtml | 1 + src/Web/Views/Shared/_LoginPartial.cshtml | 8 + src/Web/Views/_ViewImports.cshtml | 4 + .../Controllers/AccountController.cs | 31 ++ .../Extensions/EmailSenderExtensions.cs | 22 + .../Extensions/UrlHelperExtensions.cs | 33 ++ .../Pages/Account/LoginWith2fa.cshtml | 41 ++ .../Pages/Account/LoginWith2fa.cshtml.cs | 94 ++++ .../Account/LoginWithRecoveryCode.cshtml | 29 + .../Account/LoginWithRecoveryCode.cshtml.cs | 87 +++ .../Account/Manage/ChangePassword.cshtml | 35 ++ .../Account/Manage/ChangePassword.cshtml.cs | 100 ++++ .../Pages/Account/Manage/Disable2fa.cshtml | 25 + .../Pages/Account/Manage/Disable2fa.cshtml.cs | 59 +++ .../Account/Manage/EnableAuthenticator.cshtml | 53 ++ .../Manage/EnableAuthenticator.cshtml.cs | 135 +++++ .../Manage/GenerateRecoveryCodes.cshtml | 25 + .../Manage/GenerateRecoveryCodes.cshtml.cs | 48 ++ .../Pages/Account/Manage/Index.cshtml | 45 ++ .../Pages/Account/Manage/Index.cshtml.cs | 125 +++++ .../Pages/Account/Manage/ManageNavPages.cs | 31 ++ .../Account/Manage/ResetAuthenticator.cshtml | 23 + .../Manage/ResetAuthenticator.cshtml.cs | 49 ++ .../Pages/Account/Manage/SetPassword.cshtml | 35 ++ .../Account/Manage/SetPassword.cshtml.cs | 91 ++++ .../Manage/TwoFactorAuthentication.cshtml | 49 ++ .../Manage/TwoFactorAuthentication.cshtml.cs | 51 ++ .../Pages/Account/Manage/_Layout.cshtml | 23 + .../Pages/Account/Manage/_ManageNav.cshtml | 6 + .../Account/Manage/_StatusMessage.cshtml | 10 + .../Pages/Account/Manage/_ViewImports.cshtml | 1 + .../Pages/Account/Register.cshtml.cs | 7 +- .../Pages/Account/Signin.cshtml.cs | 4 + .../Pages/Account/Signout.cshtml | 6 - .../Pages/Account/Signout.cshtml.cs | 32 -- src/WebRazorPages/Pages/_LoginPartial.cshtml | 10 +- src/WebRazorPages/Pages/_ViewImports.cshtml | 2 + src/WebRazorPages/Startup.cs | 2 + 75 files changed, 2702 insertions(+), 58 deletions(-) create mode 100644 src/ApplicationCore/Interfaces/IEmailSender.cs create mode 100644 src/Infrastructure/Services/EmailSender.cs create mode 100644 src/Web/Controllers/ManageController.cs create mode 100644 src/Web/Extensions/EmailSenderExtensions.cs create mode 100644 src/Web/Extensions/UrlHelperExtensions.cs rename src/Web/ViewModels/{ => Account}/LoginViewModel.cs (88%) create mode 100644 src/Web/ViewModels/Account/LoginWith2faViewModel.cs rename src/Web/ViewModels/{ => Account}/RegisterViewModel.cs (93%) create mode 100644 src/Web/ViewModels/Account/ResetPasswordViewModel.cs create mode 100644 src/Web/ViewModels/Manage/ChangePasswordViewModel.cs create mode 100644 src/Web/ViewModels/Manage/EnableAuthenticatorViewModel.cs create mode 100644 src/Web/ViewModels/Manage/ExternalLoginsViewModel.cs create mode 100644 src/Web/ViewModels/Manage/GenerateRecoveryCodesViewModel.cs create mode 100644 src/Web/ViewModels/Manage/IndexViewModel.cs create mode 100644 src/Web/ViewModels/Manage/RemoveLoginViewModel.cs create mode 100644 src/Web/ViewModels/Manage/SetPasswordViewModel.cs create mode 100644 src/Web/ViewModels/Manage/TwoFactorAuthenticationViewModel.cs create mode 100644 src/Web/Views/Account/Lockout.cshtml create mode 100644 src/Web/Views/Account/LoginWith2fa.cshtml create mode 100644 src/Web/Views/Manage/ChangePassword.cshtml create mode 100644 src/Web/Views/Manage/Disable2fa.cshtml create mode 100644 src/Web/Views/Manage/EnableAuthenticator.cshtml create mode 100644 src/Web/Views/Manage/ExternalLogins.cshtml create mode 100644 src/Web/Views/Manage/GenerateRecoveryCodes.cshtml create mode 100644 src/Web/Views/Manage/Index.cshtml create mode 100644 src/Web/Views/Manage/ManageNavPages.cs create mode 100644 src/Web/Views/Manage/ResetAuthenticator.cshtml create mode 100644 src/Web/Views/Manage/SetPassword.cshtml create mode 100644 src/Web/Views/Manage/TwoFactorAuthentication.cshtml create mode 100644 src/Web/Views/Manage/_Layout.cshtml create mode 100644 src/Web/Views/Manage/_ManageNav.cshtml create mode 100644 src/Web/Views/Manage/_StatusMessage.cshtml create mode 100644 src/Web/Views/Manage/_ViewImports.cshtml create mode 100644 src/WebRazorPages/Controllers/AccountController.cs create mode 100644 src/WebRazorPages/Extensions/EmailSenderExtensions.cs create mode 100644 src/WebRazorPages/Extensions/UrlHelperExtensions.cs create mode 100644 src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml create mode 100644 src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml create mode 100644 src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/Index.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/Index.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/ManageNavPages.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs create mode 100644 src/WebRazorPages/Pages/Account/Manage/_Layout.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/_ManageNav.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/_StatusMessage.cshtml create mode 100644 src/WebRazorPages/Pages/Account/Manage/_ViewImports.cshtml delete mode 100644 src/WebRazorPages/Pages/Account/Signout.cshtml delete mode 100644 src/WebRazorPages/Pages/Account/Signout.cshtml.cs diff --git a/src/ApplicationCore/Interfaces/IEmailSender.cs b/src/ApplicationCore/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..f831c73 --- /dev/null +++ b/src/ApplicationCore/Interfaces/IEmailSender.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace ApplicationCore.Interfaces +{ + + public interface IEmailSender + { + Task SendEmailAsync(string email, string subject, string message); + } +} diff --git a/src/Infrastructure/Identity/ApplicationUser.cs b/src/Infrastructure/Identity/ApplicationUser.cs index 99ee879..adb93e3 100644 --- a/src/Infrastructure/Identity/ApplicationUser.cs +++ b/src/Infrastructure/Identity/ApplicationUser.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Identity; - namespace Infrastructure.Identity { public class ApplicationUser : IdentityUser diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 0edd983..4497c83 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -21,7 +21,6 @@ - \ No newline at end of file diff --git a/src/Infrastructure/Services/EmailSender.cs b/src/Infrastructure/Services/EmailSender.cs new file mode 100644 index 0000000..979ca73 --- /dev/null +++ b/src/Infrastructure/Services/EmailSender.cs @@ -0,0 +1,16 @@ +using ApplicationCore.Interfaces; +using System.Threading.Tasks; + +namespace Infrastructure.Services +{ + // This class is used by the application to send email for account confirmation and password reset. + // For more details see https://go.microsoft.com/fwlink/?LinkID=532713 + public class EmailSender : IEmailSender + { + public Task SendEmailAsync(string email, string subject, string message) + { + // TODO: Wire this up to actual email sending logic via SendGrid, local SMTP, etc. + return Task.CompletedTask; + } + } +} diff --git a/src/Web/Controllers/AccountController.cs b/src/Web/Controllers/AccountController.cs index 89f08cf..185a82c 100644 --- a/src/Web/Controllers/AccountController.cs +++ b/src/Web/Controllers/AccountController.cs @@ -1,15 +1,17 @@ -using Microsoft.eShopWeb.ViewModels; -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; -using Infrastructure.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ViewModels.Account; using System; -using Microsoft.AspNetCore.Authentication; -using ApplicationCore.Interfaces; +using System.Threading.Tasks; +using Web.ViewModels.Account; namespace Microsoft.eShopWeb.Controllers { + [Route("[controller]/[action]")] [Authorize] public class AccountController : Controller @@ -17,15 +19,18 @@ namespace Microsoft.eShopWeb.Controllers private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly IBasketService _basketService; + private readonly IAppLogger _logger; public AccountController( UserManager userManager, SignInManager signInManager, - IBasketService basketService) + IBasketService basketService, + IAppLogger logger) { _userManager = userManager; _signInManager = signInManager; _basketService = basketService; + _logger = logger; } // GET: /Account/SignIn @@ -58,6 +63,10 @@ namespace Microsoft.eShopWeb.Controllers ViewData["ReturnUrl"] = returnUrl; var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false); + if (result.RequiresTwoFactor) + { + return RedirectToAction(nameof(LoginWith2fa), new { returnUrl, model.RememberMe }); + } if (result.Succeeded) { string anonymousBasketId = Request.Cookies[Constants.BASKET_COOKIENAME]; @@ -72,6 +81,70 @@ namespace Microsoft.eShopWeb.Controllers return View(model); } + [HttpGet] + [AllowAnonymous] + public async Task LoginWith2fa(bool rememberMe, string returnUrl = null) + { + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + + if (user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } + + var model = new LoginWith2faViewModel { RememberMe = rememberMe }; + ViewData["ReturnUrl"] = returnUrl; + + return View(model); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWith2fa(LoginWith2faViewModel model, bool rememberMe, string returnUrl = null) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty); + + var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine); + + if (result.Succeeded) + { + _logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id); + return RedirectToLocal(returnUrl); + } + else if (result.IsLockedOut) + { + _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); + return RedirectToAction(nameof(Lockout)); + } + else + { + _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id); + ModelState.AddModelError(string.Empty, "Invalid authenticator code."); + return View(); + } + } + + [HttpGet] + [AllowAnonymous] + public IActionResult Lockout() + { + return View(); + } + + [HttpPost] [ValidateAntiForgeryToken] public async Task SignOut() @@ -107,6 +180,35 @@ namespace Microsoft.eShopWeb.Controllers return View(model); } + [HttpGet] + [AllowAnonymous] + public async Task ConfirmEmail(string userId, string code) + { + if (userId == null || code == null) + { + return RedirectToAction(nameof(CatalogController.Index), "Catalog"); + } + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{userId}'."); + } + var result = await _userManager.ConfirmEmailAsync(user, code); + return View(result.Succeeded ? "ConfirmEmail" : "Error"); + } + + [HttpGet] + [AllowAnonymous] + public IActionResult ResetPassword(string code = null) + { + if (code == null) + { + throw new ApplicationException("A code must be supplied for password reset."); + } + var model = new ResetPasswordViewModel { Code = code }; + return View(model); + } + private IActionResult RedirectToLocal(string returnUrl) { if (Url.IsLocalUrl(returnUrl)) diff --git a/src/Web/Controllers/ManageController.cs b/src/Web/Controllers/ManageController.cs new file mode 100644 index 0000000..dd77c0a --- /dev/null +++ b/src/Web/Controllers/ManageController.cs @@ -0,0 +1,499 @@ +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.eShopWeb.ViewModels.Manage; +using Microsoft.eShopWeb.Services; +using System; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.Controllers +{ + [Authorize] + [Route("[controller]/[action]")] + public class ManageController : Controller + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IEmailSender _emailSender; + private readonly IAppLogger _logger; + private readonly UrlEncoder _urlEncoder; + + private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + public ManageController( + UserManager userManager, + SignInManager signInManager, + IEmailSender emailSender, + IAppLogger logger, + UrlEncoder urlEncoder) + { + _userManager = userManager; + _signInManager = signInManager; + _emailSender = emailSender; + _logger = logger; + _urlEncoder = urlEncoder; + } + + [TempData] + public string StatusMessage { get; set; } + + [HttpGet] + public async Task Index() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var model = new IndexViewModel + { + Username = user.UserName, + Email = user.Email, + PhoneNumber = user.PhoneNumber, + IsEmailConfirmed = user.EmailConfirmed, + StatusMessage = StatusMessage + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Index(IndexViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var email = user.Email; + if (model.Email != email) + { + var setEmailResult = await _userManager.SetEmailAsync(user, model.Email); + if (!setEmailResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred setting email for user with ID '{user.Id}'."); + } + } + + var phoneNumber = user.PhoneNumber; + if (model.PhoneNumber != phoneNumber) + { + var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, model.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred setting phone number for user with ID '{user.Id}'."); + } + } + + StatusMessage = "Your profile has been updated"; + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SendVerificationEmail(IndexViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme); + var email = user.Email; + await _emailSender.SendEmailConfirmationAsync(email, callbackUrl); + + StatusMessage = "Verification email sent. Please check your email."; + return RedirectToAction(nameof(Index)); + } + + [HttpGet] + public async Task ChangePassword() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + if (!hasPassword) + { + return RedirectToAction(nameof(SetPassword)); + } + + var model = new ChangePasswordViewModel { StatusMessage = StatusMessage }; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ChangePassword(ChangePasswordViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var changePasswordResult = await _userManager.ChangePasswordAsync(user, model.OldPassword, model.NewPassword); + if (!changePasswordResult.Succeeded) + { + AddErrors(changePasswordResult); + return View(model); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + _logger.LogInformation("User changed their password successfully."); + StatusMessage = "Your password has been changed."; + + return RedirectToAction(nameof(ChangePassword)); + } + + [HttpGet] + public async Task SetPassword() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + + if (hasPassword) + { + return RedirectToAction(nameof(ChangePassword)); + } + + var model = new SetPasswordViewModel { StatusMessage = StatusMessage }; + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SetPassword(SetPasswordViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var addPasswordResult = await _userManager.AddPasswordAsync(user, model.NewPassword); + if (!addPasswordResult.Succeeded) + { + AddErrors(addPasswordResult); + return View(model); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + StatusMessage = "Your password has been set."; + + return RedirectToAction(nameof(SetPassword)); + } + + [HttpGet] + public async Task ExternalLogins() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var model = new ExternalLoginsViewModel { CurrentLogins = await _userManager.GetLoginsAsync(user) }; + model.OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()) + .Where(auth => model.CurrentLogins.All(ul => auth.Name != ul.LoginProvider)) + .ToList(); + model.ShowRemoveButton = await _userManager.HasPasswordAsync(user) || model.CurrentLogins.Count > 1; + model.StatusMessage = StatusMessage; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task LinkLogin(string provider) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + // Request a redirect to the external login provider to link a login for the current user + var redirectUrl = Url.Action(nameof(LinkLoginCallback)); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); + return new ChallengeResult(provider, properties); + } + + [HttpGet] + public async Task LinkLoginCallback() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(user.Id); + if (info == null) + { + throw new ApplicationException($"Unexpected error occurred loading external login info for user with ID '{user.Id}'."); + } + + var result = await _userManager.AddLoginAsync(user, info); + if (!result.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred adding external login for user with ID '{user.Id}'."); + } + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + StatusMessage = "The external login was added."; + return RedirectToAction(nameof(ExternalLogins)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RemoveLogin(RemoveLoginViewModel model) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var result = await _userManager.RemoveLoginAsync(user, model.LoginProvider, model.ProviderKey); + if (!result.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred removing external login for user with ID '{user.Id}'."); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + StatusMessage = "The external login was removed."; + return RedirectToAction(nameof(ExternalLogins)); + } + + [HttpGet] + public async Task TwoFactorAuthentication() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var model = new TwoFactorAuthenticationViewModel + { + HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null, + Is2faEnabled = user.TwoFactorEnabled, + RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user), + }; + + return View(model); + } + + [HttpGet] + public async Task Disable2faWarning() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!user.TwoFactorEnabled) + { + throw new ApplicationException($"Unexpected error occured disabling 2FA for user with ID '{user.Id}'."); + } + + return View(nameof(Disable2fa)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Disable2fa() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occured disabling 2FA for user with ID '{user.Id}'."); + } + + _logger.LogInformation("User with ID {UserId} has disabled 2fa.", user.Id); + return RedirectToAction(nameof(TwoFactorAuthentication)); + } + + [HttpGet] + public async Task EnableAuthenticator() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await _userManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + } + + var model = new EnableAuthenticatorViewModel + { + SharedKey = FormatKey(unformattedKey), + AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey) + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EnableAuthenticator(EnableAuthenticatorViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + // Strip spaces and hypens + var verificationCode = model.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync( + user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + ModelState.AddModelError("model.TwoFactorCode", "Verification code is invalid."); + return View(model); + } + + await _userManager.SetTwoFactorEnabledAsync(user, true); + _logger.LogInformation("User with ID {UserId} has enabled 2FA with an authenticator app.", user.Id); + return RedirectToAction(nameof(GenerateRecoveryCodes)); + } + + [HttpGet] + public IActionResult ResetAuthenticatorWarning() + { + return View(nameof(ResetAuthenticator)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ResetAuthenticator() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _userManager.SetTwoFactorEnabledAsync(user, false); + await _userManager.ResetAuthenticatorKeyAsync(user); + _logger.LogInformation("User with id '{UserId}' has reset their authentication app key.", user.Id); + + return RedirectToAction(nameof(EnableAuthenticator)); + } + + [HttpGet] + public async Task GenerateRecoveryCodes() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!user.TwoFactorEnabled) + { + throw new ApplicationException($"Cannot generate recovery codes for user with ID '{user.Id}' as they do not have 2FA enabled."); + } + + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + var model = new GenerateRecoveryCodesViewModel { RecoveryCodes = recoveryCodes.ToArray() }; + + _logger.LogInformation("User with ID {UserId} has generated new 2FA recovery codes.", user.Id); + + return View(model); + } + + private void AddErrors(IdentityResult result) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey) + { + return string.Format( + AuthenicatorUriFormat, + _urlEncoder.Encode("eShopOnWeb"), + _urlEncoder.Encode(email), + unformattedKey); + } + } +} diff --git a/src/Web/Extensions/EmailSenderExtensions.cs b/src/Web/Extensions/EmailSenderExtensions.cs new file mode 100644 index 0000000..28a2ac4 --- /dev/null +++ b/src/Web/Extensions/EmailSenderExtensions.cs @@ -0,0 +1,15 @@ +using ApplicationCore.Interfaces; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.Services +{ + public static class EmailSenderExtensions + { + public static Task SendEmailConfirmationAsync(this IEmailSender emailSender, string email, string link) + { + return emailSender.SendEmailAsync(email, "Confirm your email", + $"Please confirm your account by clicking this link: link"); + } + } +} diff --git a/src/Web/Extensions/UrlHelperExtensions.cs b/src/Web/Extensions/UrlHelperExtensions.cs new file mode 100644 index 0000000..02d47f8 --- /dev/null +++ b/src/Web/Extensions/UrlHelperExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.eShopWeb.Controllers; + +namespace Microsoft.AspNetCore.Mvc +{ + public static class UrlHelperExtensions + { + public static string EmailConfirmationLink(this IUrlHelper urlHelper, string userId, string code, string scheme) + { + return urlHelper.Action( + action: nameof(AccountController.ConfirmEmail), + controller: "Account", + values: new { userId, code }, + protocol: scheme); + } + + public static string ResetPasswordCallbackLink(this IUrlHelper urlHelper, string userId, string code, string scheme) + { + return urlHelper.Action( + action: nameof(AccountController.ResetPassword), + controller: "Account", + values: new { userId, code }, + protocol: scheme); + } + } +} diff --git a/src/Web/Startup.cs b/src/Web/Startup.cs index b413c6f..6a513dd 100644 --- a/src/Web/Startup.cs +++ b/src/Web/Startup.cs @@ -3,6 +3,7 @@ using ApplicationCore.Services; using Infrastructure.Data; using Infrastructure.Identity; using Infrastructure.Logging; +using Infrastructure.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -100,6 +101,7 @@ namespace Microsoft.eShopWeb services.AddSingleton(new UriComposer(Configuration.Get())); services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>)); + services.AddTransient(); // Add memory cache services services.AddMemoryCache(); diff --git a/src/Web/ViewModels/LoginViewModel.cs b/src/Web/ViewModels/Account/LoginViewModel.cs similarity index 88% rename from src/Web/ViewModels/LoginViewModel.cs rename to src/Web/ViewModels/Account/LoginViewModel.cs index 5a75343..72ddabc 100644 --- a/src/Web/ViewModels/LoginViewModel.cs +++ b/src/Web/ViewModels/Account/LoginViewModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Microsoft.eShopWeb.ViewModels +namespace Microsoft.eShopWeb.ViewModels.Account { public class LoginViewModel { diff --git a/src/Web/ViewModels/Account/LoginWith2faViewModel.cs b/src/Web/ViewModels/Account/LoginWith2faViewModel.cs new file mode 100644 index 0000000..640fe15 --- /dev/null +++ b/src/Web/ViewModels/Account/LoginWith2faViewModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Account +{ + public class LoginWith2faViewModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + public string TwoFactorCode { get; set; } + + [Display(Name = "Remember this machine")] + public bool RememberMachine { get; set; } + + public bool RememberMe { get; set; } + } +} diff --git a/src/Web/ViewModels/RegisterViewModel.cs b/src/Web/ViewModels/Account/RegisterViewModel.cs similarity index 93% rename from src/Web/ViewModels/RegisterViewModel.cs rename to src/Web/ViewModels/Account/RegisterViewModel.cs index be6e2c3..ae17369 100644 --- a/src/Web/ViewModels/RegisterViewModel.cs +++ b/src/Web/ViewModels/Account/RegisterViewModel.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace Microsoft.eShopWeb.ViewModels +namespace Microsoft.eShopWeb.ViewModels.Account { public class RegisterViewModel { diff --git a/src/Web/ViewModels/Account/ResetPasswordViewModel.cs b/src/Web/ViewModels/Account/ResetPasswordViewModel.cs new file mode 100644 index 0000000..bf1a9b3 --- /dev/null +++ b/src/Web/ViewModels/Account/ResetPasswordViewModel.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Web.ViewModels.Account +{ + public class ResetPasswordViewModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + public string Password { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + + public string Code { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/ChangePasswordViewModel.cs b/src/Web/ViewModels/Manage/ChangePasswordViewModel.cs new file mode 100644 index 0000000..81e7f06 --- /dev/null +++ b/src/Web/ViewModels/Manage/ChangePasswordViewModel.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class ChangePasswordViewModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + + public string StatusMessage { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/EnableAuthenticatorViewModel.cs b/src/Web/ViewModels/Manage/EnableAuthenticatorViewModel.cs new file mode 100644 index 0000000..fffad4a --- /dev/null +++ b/src/Web/ViewModels/Manage/EnableAuthenticatorViewModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class EnableAuthenticatorViewModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } + + [ReadOnly(true)] + public string SharedKey { get; set; } + + public string AuthenticatorUri { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/ExternalLoginsViewModel.cs b/src/Web/ViewModels/Manage/ExternalLoginsViewModel.cs new file mode 100644 index 0000000..3187d7d --- /dev/null +++ b/src/Web/ViewModels/Manage/ExternalLoginsViewModel.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using System.Collections.Generic; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class ExternalLoginsViewModel + { + public IList CurrentLogins { get; set; } + public IList OtherLogins { get; set; } + public bool ShowRemoveButton { get; set; } + public string StatusMessage { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/GenerateRecoveryCodesViewModel.cs b/src/Web/ViewModels/Manage/GenerateRecoveryCodesViewModel.cs new file mode 100644 index 0000000..2809726 --- /dev/null +++ b/src/Web/ViewModels/Manage/GenerateRecoveryCodesViewModel.cs @@ -0,0 +1,7 @@ +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class GenerateRecoveryCodesViewModel + { + public string[] RecoveryCodes { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/IndexViewModel.cs b/src/Web/ViewModels/Manage/IndexViewModel.cs new file mode 100644 index 0000000..a5a2ee0 --- /dev/null +++ b/src/Web/ViewModels/Manage/IndexViewModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class IndexViewModel + { + public string Username { get; set; } + + public bool IsEmailConfirmed { get; set; } + + [Required] + [EmailAddress] + public string Email { get; set; } + + [Phone] + [Display(Name = "Phone number")] + public string PhoneNumber { get; set; } + + public string StatusMessage { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/RemoveLoginViewModel.cs b/src/Web/ViewModels/Manage/RemoveLoginViewModel.cs new file mode 100644 index 0000000..f5a9b03 --- /dev/null +++ b/src/Web/ViewModels/Manage/RemoveLoginViewModel.cs @@ -0,0 +1,8 @@ +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class RemoveLoginViewModel + { + public string LoginProvider { get; set; } + public string ProviderKey { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/SetPasswordViewModel.cs b/src/Web/ViewModels/Manage/SetPasswordViewModel.cs new file mode 100644 index 0000000..4e74b8f --- /dev/null +++ b/src/Web/ViewModels/Manage/SetPasswordViewModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class SetPasswordViewModel + { + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + + public string StatusMessage { get; set; } + } +} diff --git a/src/Web/ViewModels/Manage/TwoFactorAuthenticationViewModel.cs b/src/Web/ViewModels/Manage/TwoFactorAuthenticationViewModel.cs new file mode 100644 index 0000000..840da34 --- /dev/null +++ b/src/Web/ViewModels/Manage/TwoFactorAuthenticationViewModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.eShopWeb.ViewModels.Manage +{ + public class TwoFactorAuthenticationViewModel + { + public bool HasAuthenticator { get; set; } + public int RecoveryCodesLeft { get; set; } + public bool Is2faEnabled { get; set; } + } +} diff --git a/src/Web/Views/Account/Lockout.cshtml b/src/Web/Views/Account/Lockout.cshtml new file mode 100644 index 0000000..d2d4bd7 --- /dev/null +++ b/src/Web/Views/Account/Lockout.cshtml @@ -0,0 +1,8 @@ +@{ + ViewData["Title"] = "Locked out"; +} + +
+

@ViewData["Title"]

+

This account has been locked out, please try again later.

+
diff --git a/src/Web/Views/Account/LoginWith2fa.cshtml b/src/Web/Views/Account/LoginWith2fa.cshtml new file mode 100644 index 0000000..2b5097a --- /dev/null +++ b/src/Web/Views/Account/LoginWith2fa.cshtml @@ -0,0 +1,40 @@ +@model LoginWith2faViewModel +@{ + ViewData["Title"] = "Two-factor authentication"; +} + +

@ViewData["Title"]

+
+

Your login is protected with an authenticator app. Enter your authenticator code below.

+
+
+
+ +
+
+ + + +
+
+
+ +
+
+
+ +
+
+
+
+

+ Don't have access to your authenticator device? You can + log in with a recovery code. +

+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} \ No newline at end of file diff --git a/src/Web/Views/Account/Signin.cshtml b/src/Web/Views/Account/Signin.cshtml index dc5b3f8..902c6f5 100644 --- a/src/Web/Views/Account/Signin.cshtml +++ b/src/Web/Views/Account/Signin.cshtml @@ -1,6 +1,4 @@ -@using System.Collections.Generic -@using Microsoft.AspNetCore.Http -@using Microsoft.AspNetCore.Http.Authentication +@using Microsoft.eShopWeb.ViewModels.Account @model LoginViewModel @{ ViewData["Title"] = "Log in"; @@ -15,7 +13,7 @@
-
+

ARE YOU REGISTERED?

diff --git a/src/Web/Views/Manage/ChangePassword.cshtml b/src/Web/Views/Manage/ChangePassword.cshtml new file mode 100644 index 0000000..0f7b041 --- /dev/null +++ b/src/Web/Views/Manage/ChangePassword.cshtml @@ -0,0 +1,35 @@ +@model ChangePasswordViewModel +@{ + ViewData["Title"] = "Change password"; + ViewData.AddActivePage(ManageNavPages.ChangePassword); +} + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+ + +
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/Web/Views/Manage/Disable2fa.cshtml b/src/Web/Views/Manage/Disable2fa.cshtml new file mode 100644 index 0000000..cdd79cb --- /dev/null +++ b/src/Web/Views/Manage/Disable2fa.cshtml @@ -0,0 +1,24 @@ +@{ + ViewData["Title"] = "Disable two-factor authentication (2FA)"; + ViewData.AddActivePage(ManageNavPages.TwoFactorAuthentication); +} + +

@ViewData["Title"]

+ + + +
+
+ +
+
diff --git a/src/Web/Views/Manage/EnableAuthenticator.cshtml b/src/Web/Views/Manage/EnableAuthenticator.cshtml new file mode 100644 index 0000000..79693d7 --- /dev/null +++ b/src/Web/Views/Manage/EnableAuthenticator.cshtml @@ -0,0 +1,52 @@ +@model EnableAuthenticatorViewModel +@{ + ViewData["Title"] = "Enable authenticator"; + ViewData.AddActivePage(ManageNavPages.TwoFactorAuthentication); +} + +

@ViewData["Title"]

+
+

To use an authenticator app go through the following steps:

+
    +
  1. +

    + Download a two-factor authenticator app like Microsoft Authenticator for + Windows Phone, + Android and + iOS or + Google Authenticator for + Android and + iOS. +

    +
  2. +
  3. +

    Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.

    +
    To enable QR code generation please read our documentation.
    +
    +
    +
  4. +
  5. +

    + Once you have scanned the QR code or input the key above, your two factor authentication app will provide you + with a unique code. Enter the code in the confirmation box below. +

    +
    +
    +
    +
    + + + +
    + +
    +
    +
    +
    +
  6. +
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/Web/Views/Manage/ExternalLogins.cshtml b/src/Web/Views/Manage/ExternalLogins.cshtml new file mode 100644 index 0000000..e6f5687 --- /dev/null +++ b/src/Web/Views/Manage/ExternalLogins.cshtml @@ -0,0 +1,52 @@ +@model ExternalLoginsViewModel +@{ + ViewData["Title"] = "Manage your external logins"; + ViewData.AddActivePage(ManageNavPages.ExternalLogins); +} + +@Html.Partial("_StatusMessage", Model.StatusMessage) +@if (Model.CurrentLogins?.Count > 0) +{ +

Registered Logins

+ + + @foreach (var login in Model.CurrentLogins) + { + + + + + } + +
@login.LoginProvider + @if (Model.ShowRemoveButton) + { +
+
+ + + +
+
+ } + else + { + @:   + } +
+} +@if (Model.OtherLogins?.Count > 0) +{ +

Add another service to log in.

+
+
+
+

+ @foreach (var provider in Model.OtherLogins) + { + + } +

+
+
+} diff --git a/src/Web/Views/Manage/GenerateRecoveryCodes.cshtml b/src/Web/Views/Manage/GenerateRecoveryCodes.cshtml new file mode 100644 index 0000000..669d13e --- /dev/null +++ b/src/Web/Views/Manage/GenerateRecoveryCodes.cshtml @@ -0,0 +1,24 @@ +@model GenerateRecoveryCodesViewModel +@{ + ViewData["Title"] = "Recovery codes"; + ViewData.AddActivePage(ManageNavPages.TwoFactorAuthentication); +} + +

@ViewData["Title"]

+ +
+
+ @for (var row = 0; row < Model.RecoveryCodes.Count(); row += 2) + { + @Model.RecoveryCodes[row] @Model.RecoveryCodes[row + 1]
+ } +
+
\ No newline at end of file diff --git a/src/Web/Views/Manage/Index.cshtml b/src/Web/Views/Manage/Index.cshtml new file mode 100644 index 0000000..c141c95 --- /dev/null +++ b/src/Web/Views/Manage/Index.cshtml @@ -0,0 +1,45 @@ +@model IndexViewModel +@{ + ViewData["Title"] = "Profile"; + ViewData.AddActivePage(ManageNavPages.Index); +} + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) +
+
+
+
+
+ + +
+
+ + @if (Model.IsEmailConfirmed) + { +
+ + +
+ } + else + { + + + } + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/Web/Views/Manage/ManageNavPages.cs b/src/Web/Views/Manage/ManageNavPages.cs new file mode 100644 index 0000000..585d17e --- /dev/null +++ b/src/Web/Views/Manage/ManageNavPages.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; + +namespace Microsoft.eShopWeb.Views.Manage +{ + public static class ManageNavPages + { + public static string ActivePageKey => "ActivePage"; + + public static string Index => "Index"; + + public static string ChangePassword => "ChangePassword"; + + public static string ExternalLogins => "ExternalLogins"; + + public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + + public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); + + public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); + + public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins); + + public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + + public static string PageNavClass(ViewContext viewContext, string page) + { + var activePage = viewContext.ViewData["ActivePage"] as string; + return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; + } + + public static void AddActivePage(this ViewDataDictionary viewData, string activePage) => viewData[ActivePageKey] = activePage; + } +} diff --git a/src/Web/Views/Manage/ResetAuthenticator.cshtml b/src/Web/Views/Manage/ResetAuthenticator.cshtml new file mode 100644 index 0000000..54a2361 --- /dev/null +++ b/src/Web/Views/Manage/ResetAuthenticator.cshtml @@ -0,0 +1,21 @@ +@{ + ViewData["Title"] = "Reset authenticator key"; + ViewData.AddActivePage(ManageNavPages.TwoFactorAuthentication); +} + +

@ViewData["Title"]

+ +
+
+ +
+
\ No newline at end of file diff --git a/src/Web/Views/Manage/SetPassword.cshtml b/src/Web/Views/Manage/SetPassword.cshtml new file mode 100644 index 0000000..56c3599 --- /dev/null +++ b/src/Web/Views/Manage/SetPassword.cshtml @@ -0,0 +1,34 @@ +@model SetPasswordViewModel +@{ + ViewData["Title"] = "Set password"; + ViewData.AddActivePage(ManageNavPages.ChangePassword); +} + +

Set your password

+@Html.Partial("_StatusMessage", Model.StatusMessage) +

+ You do not have a local username/password for this site. Add a local + account so you can log in without an external login. +

+
+
+
+
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/Web/Views/Manage/TwoFactorAuthentication.cshtml b/src/Web/Views/Manage/TwoFactorAuthentication.cshtml new file mode 100644 index 0000000..a2b52ac --- /dev/null +++ b/src/Web/Views/Manage/TwoFactorAuthentication.cshtml @@ -0,0 +1,49 @@ +@model TwoFactorAuthenticationViewModel +@{ + ViewData["Title"] = "Two-factor authentication"; + ViewData.AddActivePage(ManageNavPages.TwoFactorAuthentication); +} + +

@ViewData["Title"]

+@if (Model.Is2faEnabled) +{ + if (Model.RecoveryCodesLeft == 0) + { +
+ You have no recovery codes left. +

You must generate a new set of recovery codes before you can log in with a recovery code.

+
+ } + else if (Model.RecoveryCodesLeft == 1) + { +
+ You have 1 recovery code left. +

You can generate a new set of recovery codes.

+
+ } + else if (Model.RecoveryCodesLeft <= 3) + { +
+ You have @Model.RecoveryCodesLeft recovery codes left. +

You should generate a new set of recovery codes.

+
+ } + + Disable 2FA + Reset recovery codes +} + +
Authenticator app
+@if (!Model.HasAuthenticator) +{ + Add authenticator app +} +else +{ + Configure authenticator app + Reset authenticator key +} + +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/Web/Views/Manage/_Layout.cshtml b/src/Web/Views/Manage/_Layout.cshtml new file mode 100644 index 0000000..95dc87d --- /dev/null +++ b/src/Web/Views/Manage/_Layout.cshtml @@ -0,0 +1,23 @@ +@{ + Layout = "/Views/Shared/_Layout.cshtml"; +} + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ @await Html.PartialAsync("_ManageNav") +
+
+ @RenderBody() +
+
+
+ +@section Scripts { + @RenderSection("Scripts", required: false) +} + diff --git a/src/Web/Views/Manage/_ManageNav.cshtml b/src/Web/Views/Manage/_ManageNav.cshtml new file mode 100644 index 0000000..00c2384 --- /dev/null +++ b/src/Web/Views/Manage/_ManageNav.cshtml @@ -0,0 +1,15 @@ +@inject SignInManager SignInManager +@{ + var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); +} + + + diff --git a/src/Web/Views/Manage/_StatusMessage.cshtml b/src/Web/Views/Manage/_StatusMessage.cshtml new file mode 100644 index 0000000..e996841 --- /dev/null +++ b/src/Web/Views/Manage/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +} diff --git a/src/Web/Views/Manage/_ViewImports.cshtml b/src/Web/Views/Manage/_ViewImports.cshtml new file mode 100644 index 0000000..2b83661 --- /dev/null +++ b/src/Web/Views/Manage/_ViewImports.cshtml @@ -0,0 +1 @@ +@using Microsoft.eShopWeb.Views.Manage \ No newline at end of file diff --git a/src/Web/Views/Shared/_LoginPartial.cshtml b/src/Web/Views/Shared/_LoginPartial.cshtml index 3d97d0e..6f03b12 100644 --- a/src/Web/Views/Shared/_LoginPartial.cshtml +++ b/src/Web/Views/Shared/_LoginPartial.cshtml @@ -21,6 +21,14 @@ + + +
My account
+ +
+ diff --git a/src/Web/Views/_ViewImports.cshtml b/src/Web/Views/_ViewImports.cshtml index f3e172c..ca9578b 100644 --- a/src/Web/Views/_ViewImports.cshtml +++ b/src/Web/Views/_ViewImports.cshtml @@ -1,3 +1,7 @@ @using Microsoft.eShopWeb @using Microsoft.eShopWeb.ViewModels +@using Microsoft.eShopWeb.ViewModels.Account +@using Microsoft.eShopWeb.ViewModels.Manage +@using Microsoft.AspNetCore.Identity +@using Infrastructure.Identity @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/WebRazorPages/Controllers/AccountController.cs b/src/WebRazorPages/Controllers/AccountController.cs new file mode 100644 index 0000000..e394f91 --- /dev/null +++ b/src/WebRazorPages/Controllers/AccountController.cs @@ -0,0 +1,31 @@ +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Controllers +{ + [Route("[controller]/[action]")] + public class AccountController : Controller + { + private readonly SignInManager _signInManager; + private readonly IAppLogger _logger; + + public AccountController(SignInManager signInManager, + IAppLogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout() + { + await _signInManager.SignOutAsync(); + _logger.LogInformation("User logged out."); + return RedirectToPage("/Index"); + } + } +} diff --git a/src/WebRazorPages/Extensions/EmailSenderExtensions.cs b/src/WebRazorPages/Extensions/EmailSenderExtensions.cs new file mode 100644 index 0000000..46af85f --- /dev/null +++ b/src/WebRazorPages/Extensions/EmailSenderExtensions.cs @@ -0,0 +1,22 @@ +using ApplicationCore.Interfaces; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc +{ + public static class EmailSenderExtensions + { + public static Task SendEmailConfirmationAsync(this IEmailSender emailSender, string email, string link) + { + return emailSender.SendEmailAsync(email, "Confirm your email", + $"Please confirm your account by clicking here."); + } + + public static Task SendResetPasswordAsync(this IEmailSender emailSender, string email, string callbackUrl) + { + return emailSender.SendEmailAsync(email, "Reset Password", + $"Please reset your password by clicking here."); + } + } + +} diff --git a/src/WebRazorPages/Extensions/UrlHelperExtensions.cs b/src/WebRazorPages/Extensions/UrlHelperExtensions.cs new file mode 100644 index 0000000..48fb991 --- /dev/null +++ b/src/WebRazorPages/Extensions/UrlHelperExtensions.cs @@ -0,0 +1,33 @@ +namespace Microsoft.AspNetCore.Mvc +{ + public static class UrlHelperExtensions + { + public static string GetLocalUrl(this IUrlHelper urlHelper, string localUrl) + { + if (!urlHelper.IsLocalUrl(localUrl)) + { + return urlHelper.Page("/Index"); + } + + return localUrl; + } + + public static string EmailConfirmationLink(this IUrlHelper urlHelper, string userId, string code, string scheme) + { + return urlHelper.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { userId, code }, + protocol: scheme); + } + + public static string ResetPasswordCallbackLink(this IUrlHelper urlHelper, string userId, string code, string scheme) + { + return urlHelper.Page( + "/Account/ResetPassword", + pageHandler: null, + values: new { userId, code }, + protocol: scheme); + } + } +} diff --git a/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml b/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml new file mode 100644 index 0000000..032ecd9 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml @@ -0,0 +1,41 @@ +@page +@model LoginWith2faModel +@{ + ViewData["Title"] = "Two-factor authentication"; +} + +

@ViewData["Title"]

+
+

Your login is protected with an authenticator app. Enter your authenticator code below.

+
+
+
+ +
+
+ + + +
+
+
+ +
+
+
+ +
+
+
+
+

+ Don't have access to your authenticator device? You can + log in with a recovery code. +

+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml.cs b/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml.cs new file mode 100644 index 0000000..c46e971 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/LoginWith2fa.cshtml.cs @@ -0,0 +1,94 @@ +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account +{ + public class LoginWith2faModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly IAppLogger _logger; + + public LoginWith2faModel(SignInManager signInManager, + IAppLogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + public bool RememberMe { get; set; } + + public string ReturnUrl { get; set; } + + public class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + public string TwoFactorCode { get; set; } + + [Display(Name = "Remember this machine")] + public bool RememberMachine { get; set; } + } + + public async Task OnGetAsync(bool rememberMe, string returnUrl = null) + { + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + + if (user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } + + ReturnUrl = returnUrl; + RememberMe = rememberMe; + + return Page(); + } + + public async Task OnPostAsync(bool rememberMe, string returnUrl = null) + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } + + var authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty); + + var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine); + + if (result.Succeeded) + { + _logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id); + return LocalRedirect(Url.GetLocalUrl(returnUrl)); + } + else if (result.IsLockedOut) + { + _logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id); + return RedirectToPage("./Lockout"); + } + else + { + _logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id); + ModelState.AddModelError(string.Empty, "Invalid authenticator code."); + return Page(); + } + } + } +} diff --git a/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml b/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml new file mode 100644 index 0000000..4b32c1d --- /dev/null +++ b/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml @@ -0,0 +1,29 @@ +@page +@model LoginWithRecoveryCodeModel +@{ + ViewData["Title"] = "Recovery code verification"; +} + +

@ViewData["Title"]

+
+

+ You have requested to log in with a recovery code. This login will not be remembered until you provide + an authenticator app code at log in or disable 2FA and log in again. +

+
+
+
+
+
+ + + +
+ +
+
+
+ + @section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml.cs b/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml.cs new file mode 100644 index 0000000..10456b4 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/LoginWithRecoveryCode.cshtml.cs @@ -0,0 +1,87 @@ +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class LoginWithRecoveryCodeModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly IAppLogger _logger; + + public LoginWithRecoveryCodeModel(SignInManager signInManager, + IAppLogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + public string ReturnUrl { get; set; } + + public class InputModel + { + [BindProperty] + [Required] + [DataType(DataType.Text)] + [Display(Name = "Recovery Code")] + public string RecoveryCode { get; set; } + } + + public async Task OnGetAsync(string returnUrl = null) + { + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } + + ReturnUrl = returnUrl; + + return Page(); + } + + public async Task OnPostAsync(string returnUrl = null) + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } + + var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); + + var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + + if (result.Succeeded) + { + _logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id); + return LocalRedirect(Url.GetLocalUrl(returnUrl)); + } + if (result.IsLockedOut) + { + _logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id); + return RedirectToPage("./Lockout"); + } + else + { + _logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id); + ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); + return Page(); + } + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml b/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml new file mode 100644 index 0000000..6aa4723 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml @@ -0,0 +1,35 @@ +@page +@model ChangePasswordModel +@{ + ViewData["Title"] = "Change password"; +} + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml.cs new file mode 100644 index 0000000..140ef94 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/ChangePassword.cshtml.cs @@ -0,0 +1,100 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class ChangePasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public ChangePasswordModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public class InputModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + if (!hasPassword) + { + return RedirectToPage("./SetPassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); + if (!changePasswordResult.Succeeded) + { + foreach (var error in changePasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + _logger.LogInformation("User changed their password successfully."); + StatusMessage = "Your password has been changed."; + + return RedirectToPage(); + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml b/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml new file mode 100644 index 0000000..954093e --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml @@ -0,0 +1,25 @@ +@page +@model Disable2faModel +@{ + ViewData["Title"] = "Disable two-factor authentication (2FA)"; + ViewData["ActivePage"] = "TwoFactorAuthentication"; +} + +

@ViewData["Title"]

+ + + +
+
+ +
+
diff --git a/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml.cs new file mode 100644 index 0000000..fd3eda7 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/Disable2fa.cshtml.cs @@ -0,0 +1,59 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class Disable2faModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public Disable2faModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + { + throw new ApplicationException($"Cannot disable 2FA for user with ID '{_userManager.GetUserId(User)}' as it's not currently enabled."); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred disabling 2FA for user with ID '{_userManager.GetUserId(User)}'."); + } + + _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User)); + + return RedirectToPage("./TwoFactorAuthentication"); + } + } +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml b/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml new file mode 100644 index 0000000..1d68558 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml @@ -0,0 +1,53 @@ +@page +@model EnableAuthenticatorModel +@{ + ViewData["Title"] = "Configure authenticator app"; + ViewData["ActivePage"] = "TwoFactorAuthentication"; +} + +

@ViewData["Title"]

+
+

To use an authenticator app go through the following steps:

+
    +
  1. +

    + Download a two-factor authenticator app like Microsoft Authenticator for + Windows Phone, + Android and + iOS or + Google Authenticator for + Android and + iOS. +

    +
  2. +
  3. +

    Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.

    +
    To enable QR code generation please read our documentation.
    +
    +
    +
  4. +
  5. +

    + Once you have scanned the QR code or input the key above, your two factor authentication app will provide you + with a unique code. Enter the code in the confirmation box below. +

    +
    +
    +
    +
    + + + +
    + +
    +
    +
    +
    +
  6. +
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml.cs new file mode 100644 index 0000000..9ce12bd --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/EnableAuthenticator.cshtml.cs @@ -0,0 +1,135 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class EnableAuthenticatorModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly UrlEncoder _urlEncoder; + + private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + public EnableAuthenticatorModel( + UserManager userManager, + ILogger logger, + UrlEncoder urlEncoder) + { + _userManager = userManager; + _logger = logger; + _urlEncoder = urlEncoder; + } + + public string SharedKey { get; set; } + + public string AuthenticatorUri { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadSharedKeyAndQrCodeUriAsync(user); + if (string.IsNullOrEmpty(SharedKey)) + { + await _userManager.ResetAuthenticatorKeyAsync(user); + await LoadSharedKeyAndQrCodeUriAsync(user); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadSharedKeyAndQrCodeUriAsync(user); + return Page(); + } + + // Strip spaces and hypens + var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync( + user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + ModelState.AddModelError("Input.Code", "Verification code is invalid."); + await LoadSharedKeyAndQrCodeUriAsync(user); + return Page(); + } + + await _userManager.SetTwoFactorEnabledAsync(user, true); + _logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", user.Id); + return RedirectToPage("./GenerateRecoveryCodes"); + } + + private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + if (!string.IsNullOrEmpty(unformattedKey)) + { + SharedKey = FormatKey(unformattedKey); + AuthenticatorUri = GenerateQrCodeUri(user.Email, unformattedKey); + } + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" "); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.Substring(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey) + { + return string.Format( + AuthenicatorUriFormat, + _urlEncoder.Encode("RazorPagesAuthSample2"), + _urlEncoder.Encode(email), + unformattedKey); + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml b/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml new file mode 100644 index 0000000..d058254 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml @@ -0,0 +1,25 @@ +@page +@model GenerateRecoveryCodesModel +@{ + ViewData["Title"] = "Recovery codes"; + ViewData["ActivePage"] = "TwoFactorAuthentication"; +} + +

@ViewData["Title"]

+ +
+
+ @for (var row = 0; row < Model.RecoveryCodes.Count(); row += 2) + { + @Model.RecoveryCodes[row] @Model.RecoveryCodes[row + 1]
+ } +
+
\ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs new file mode 100644 index 0000000..2bcc9bc --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs @@ -0,0 +1,48 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class GenerateRecoveryCodesModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public GenerateRecoveryCodesModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public string[] RecoveryCodes { get; set; } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!user.TwoFactorEnabled) + { + throw new ApplicationException($"Cannot generate recovery codes for user with ID '{user.Id}' as they do not have 2FA enabled."); + } + + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + RecoveryCodes = recoveryCodes.ToArray(); + + _logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", user.Id); + + return Page(); + } + } +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/Index.cshtml b/src/WebRazorPages/Pages/Account/Manage/Index.cshtml new file mode 100644 index 0000000..78a0383 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/Index.cshtml @@ -0,0 +1,45 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Profile"; +} + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) +
+
+
+
+
+ + +
+
+ + @if (Model.IsEmailConfirmed) + { +
+ + +
+ } + else + { + + + } + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/WebRazorPages/Pages/Account/Manage/Index.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/Index.cshtml.cs new file mode 100644 index 0000000..a50bda6 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/Index.cshtml.cs @@ -0,0 +1,125 @@ +using ApplicationCore.Interfaces; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public partial class IndexModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IEmailSender _emailSender; + + public IndexModel( + UserManager userManager, + SignInManager signInManager, + IEmailSender emailSender) + { + _userManager = userManager; + _signInManager = signInManager; + _emailSender = emailSender; + } + + public string Username { get; set; } + + public bool IsEmailConfirmed { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + [BindProperty] + public InputModel Input { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Phone] + [Display(Name = "Phone number")] + public string PhoneNumber { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + Username = user.UserName; + + Input = new InputModel + { + Email = user.Email, + PhoneNumber = user.PhoneNumber + }; + + IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user); + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (Input.Email != user.Email) + { + var setEmailResult = await _userManager.SetEmailAsync(user, Input.Email); + if (!setEmailResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred setting email for user with ID '{user.Id}'."); + } + } + + if (Input.PhoneNumber != user.PhoneNumber) + { + var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + throw new ApplicationException($"Unexpected error occurred setting phone number for user with ID '{user.Id}'."); + } + } + + StatusMessage = "Your profile has been updated"; + return RedirectToPage(); + } + public async Task OnPostSendVerificationEmailAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme); + await _emailSender.SendEmailConfirmationAsync(user.Email, callbackUrl); + + StatusMessage = "Verification email sent. Please check your email."; + return RedirectToPage(); + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/ManageNavPages.cs b/src/WebRazorPages/Pages/Account/Manage/ManageNavPages.cs new file mode 100644 index 0000000..908fb60 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/ManageNavPages.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.Rendering; +using System; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public static class ManageNavPages + { + public static string Index => "Index"; + + public static string ChangePassword => "ChangePassword"; + + public static string ExternalLogins => "ExternalLogins"; + + public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + + public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); + + public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); + + public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins); + + public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + + public static string PageNavClass(ViewContext viewContext, string page) + { + var activePage = viewContext.ViewData["ActivePage"] as string + ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); + return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml b/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml new file mode 100644 index 0000000..a9534ae --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml @@ -0,0 +1,23 @@ +@page +@model ResetAuthenticatorModel +@{ + ViewData["Title"] = "Reset authenticator key"; + ViewData["ActivePage"] = "TwoFactorAuthentication"; +} + +

@ViewData["Title"]

+ +
+
+ +
+
\ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml.cs new file mode 100644 index 0000000..6128e44 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/ResetAuthenticator.cshtml.cs @@ -0,0 +1,49 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class ResetAuthenticatorModel : PageModel + { + UserManager _userManager; + ILogger _logger; + + public ResetAuthenticatorModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _userManager.SetTwoFactorEnabledAsync(user, false); + await _userManager.ResetAuthenticatorKeyAsync(user); + _logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id); + + return RedirectToPage("./EnableAuthenticator"); + } + } +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml b/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml new file mode 100644 index 0000000..3f4544c --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml @@ -0,0 +1,35 @@ +@page +@model SetPasswordModel +@{ + ViewData["Title"] = "Set password"; + ViewData["ActivePage"] = "ChangePassword"; +} + +

Set your password

+@Html.Partial("_StatusMessage", Model.StatusMessage) +

+ You do not have a local username/password for this site. Add a local + account so you can log in without an external login. +

+
+
+
+
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml.cs new file mode 100644 index 0000000..9a459e4 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/SetPassword.cshtml.cs @@ -0,0 +1,91 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class SetPasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public SetPasswordModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + [BindProperty] + public InputModel Input { get; set; } + + [TempData] + public string StatusMessage { get; set; } + + public class InputModel + { + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user); + + if (hasPassword) + { + return RedirectToPage("./ChangePassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword); + if (!addPasswordResult.Succeeded) + { + foreach (var error in addPasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + StatusMessage = "Your password has been set."; + + return RedirectToPage(); + } + } +} diff --git a/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml b/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml new file mode 100644 index 0000000..fabe199 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml @@ -0,0 +1,49 @@ +@page +@model TwoFactorAuthenticationModel +@{ + ViewData["Title"] = "Two-factor authentication (2FA)"; +} + +

@ViewData["Title"]

+@if (Model.Is2faEnabled) +{ + if (Model.RecoveryCodesLeft == 0) + { +
+ You have no recovery codes left. +

You must generate a new set of recovery codes before you can log in with a recovery code.

+
+ } + else if (Model.RecoveryCodesLeft == 1) + { +
+ You have 1 recovery code left. +

You can generate a new set of recovery codes.

+
+ } + else if (Model.RecoveryCodesLeft <= 3) + { +
+ You have @Model.RecoveryCodesLeft recovery codes left. +

You should generate a new set of recovery codes.

+
+ } + + Disable 2FA + Reset recovery codes +} + +
Authenticator app
+@if (!Model.HasAuthenticator) +{ + Add authenticator app +} +else +{ + Configure authenticator app + Reset authenticator app +} + +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs b/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs new file mode 100644 index 0000000..c3e5b6a --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs @@ -0,0 +1,51 @@ +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace Microsoft.eShopWeb.RazorPages.Pages.Account.Manage +{ + public class TwoFactorAuthenticationModel : PageModel + { + private const string AuthenicatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}"; + + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public TwoFactorAuthenticationModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + public bool HasAuthenticator { get; set; } + + public int RecoveryCodesLeft { get; set; } + + [BindProperty] + public bool Is2faEnabled { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null; + Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user); + + return Page(); + } + } +} \ No newline at end of file diff --git a/src/WebRazorPages/Pages/Account/Manage/_Layout.cshtml b/src/WebRazorPages/Pages/Account/Manage/_Layout.cshtml new file mode 100644 index 0000000..c3f24b6 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/_Layout.cshtml @@ -0,0 +1,23 @@ +@{ + Layout = "/Pages/_Layout.cshtml"; +} + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ @await Html.PartialAsync("_ManageNav") +
+
+ @RenderBody() +
+
+
+ +@section Scripts { + @RenderSection("Scripts", required: false) +} + diff --git a/src/WebRazorPages/Pages/Account/Manage/_ManageNav.cshtml b/src/WebRazorPages/Pages/Account/Manage/_ManageNav.cshtml new file mode 100644 index 0000000..3008fb8 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/_ManageNav.cshtml @@ -0,0 +1,6 @@ + + diff --git a/src/WebRazorPages/Pages/Account/Manage/_StatusMessage.cshtml b/src/WebRazorPages/Pages/Account/Manage/_StatusMessage.cshtml new file mode 100644 index 0000000..e996841 --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +} diff --git a/src/WebRazorPages/Pages/Account/Manage/_ViewImports.cshtml b/src/WebRazorPages/Pages/Account/Manage/_ViewImports.cshtml new file mode 100644 index 0000000..773155e --- /dev/null +++ b/src/WebRazorPages/Pages/Account/Manage/_ViewImports.cshtml @@ -0,0 +1 @@ +@using Microsoft.eShopWeb.RazorPages.Pages.Account.Manage diff --git a/src/WebRazorPages/Pages/Account/Register.cshtml.cs b/src/WebRazorPages/Pages/Account/Register.cshtml.cs index 235700d..5571f77 100644 --- a/src/WebRazorPages/Pages/Account/Register.cshtml.cs +++ b/src/WebRazorPages/Pages/Account/Register.cshtml.cs @@ -1,10 +1,9 @@ -using System.Threading.Tasks; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.eShopWeb.RazorPages.ViewModels; -using Microsoft.AspNetCore.Identity; -using Infrastructure.Identity; using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; namespace Microsoft.eShopWeb.RazorPages.Pages.Account { diff --git a/src/WebRazorPages/Pages/Account/Signin.cshtml.cs b/src/WebRazorPages/Pages/Account/Signin.cshtml.cs index cb4557e..2996951 100644 --- a/src/WebRazorPages/Pages/Account/Signin.cshtml.cs +++ b/src/WebRazorPages/Pages/Account/Signin.cshtml.cs @@ -71,6 +71,10 @@ namespace Microsoft.eShopWeb.RazorPages.Pages.Account } return RedirectToPage(returnUrl ?? "/Index"); } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = LoginDetails.RememberMe }); + } ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); } diff --git a/src/WebRazorPages/Pages/Account/Signout.cshtml b/src/WebRazorPages/Pages/Account/Signout.cshtml deleted file mode 100644 index 02b31b5..0000000 --- a/src/WebRazorPages/Pages/Account/Signout.cshtml +++ /dev/null @@ -1,6 +0,0 @@ -@page -@model SignoutModel -@{ - ViewData["Title"] = "Signing out"; -} -

Signing out...

diff --git a/src/WebRazorPages/Pages/Account/Signout.cshtml.cs b/src/WebRazorPages/Pages/Account/Signout.cshtml.cs deleted file mode 100644 index 85c2fae..0000000 --- a/src/WebRazorPages/Pages/Account/Signout.cshtml.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.AspNetCore.Identity; -using Infrastructure.Identity; -using Microsoft.AspNetCore.Mvc; - -namespace Microsoft.eShopWeb.RazorPages.Pages.Account -{ - public class SignoutModel : PageModel - { - private readonly SignInManager _signInManager; - - public SignoutModel(SignInManager signInManager) - { - _signInManager = signInManager; - } - - public async Task OnGet() - { - await _signInManager.SignOutAsync(); - - return RedirectToPage("/Index"); - } - - public async Task OnPost() - { - await _signInManager.SignOutAsync(); - - return RedirectToPage("/Index"); - } - } -} diff --git a/src/WebRazorPages/Pages/_LoginPartial.cshtml b/src/WebRazorPages/Pages/_LoginPartial.cshtml index 58740e6..708fb14 100644 --- a/src/WebRazorPages/Pages/_LoginPartial.cshtml +++ b/src/WebRazorPages/Pages/_LoginPartial.cshtml @@ -4,7 +4,7 @@ {