From e295fae626347cc3d993c060460c2377e50ea968 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 18 May 2023 12:27:27 -0700 Subject: [PATCH] Add two-factor authentication (#13704) --- .../ViewModels/RoleLoginSettingsViewModel.cs | 10 + .../OrchardCore.Users/Assets.json | 6 + .../Controllers/AccountBaseController.cs | 50 + .../Controllers/AccountController.cs | 47 +- .../TwoFactorAuthenticationController.cs | 672 ++++++++++ .../Drivers/LoginSettingsDisplayDriver.cs | 52 +- .../Drivers/RoleLoginSettingsDisplayDriver.cs | 93 ++ ...FactorAuthenticationAuthorizationFilter.cs | 53 + .../OrchardCore.Users/Models/LoginSettings.cs | 19 - .../TwoFactorAuthenticationClaimsProvider.cs | 48 + .../Services/UsersThemeSelector.cs | 5 + .../OrchardCore.Users/Startup.cs | 79 +- .../UserOptionsConfiguration.cs | 5 + .../EnableAuthenticatorViewModel.cs | 15 + .../LoginWithRecoveryCodeViewModel.cs | 11 + .../ViewModels/LoginWithTwoFaViewModel.cs | 19 + .../ViewModels/RoleLoginSettingsViewModel.cs | 14 + .../ViewModels/ShowRecoveryCodesViewModel.cs | 6 + .../TwoFactorAuthenticationViewModel.cs | 17 + ...sEnableTwoFactorAuthentication.Edit.cshtml | 9 + .../Views/LoginSettingsRoles.Edit.cshtml | 24 + ...ettingsTwoFactorAuthentication.Edit.cshtml | 51 + .../TwoFactorAuthentication/Disable2FA.cshtml | 17 + .../EnableAuthenticator.cshtml | 63 + .../GenerateRecoveryCodes.cshtml | 12 + .../TwoFactorAuthentication/Index.cshtml | 82 ++ .../LoginWith2FA.cshtml | 61 + .../LoginWithRecoveryCode.cshtml | 33 + .../ResetAuthenticator.cshtml | 13 + .../ShowRecoveryCodes.cshtml | 18 + .../OrchardCore.Users/Views/UserMenu.cshtml | 13 + .../OrchardCore.Users/package-lock.json | 21 + .../OrchardCore.Users/package.json | 14 + .../wwwroot/Scripts/qrcode.js | 1118 +++++++++++++++++ .../wwwroot/Scripts/qrcode.min.js | 1 + .../TheTheme/Views/LoginMenu.cshtml | 5 + .../UserOptions.cs | 7 + .../UsersServiceCollectionExtensions.cs | 3 + .../Models/LoginSettings.cs | 37 + .../OrchardCore.Users.Core/Models/User.cs | 19 + .../Services/LoginSettingsExtensions.cs | 29 + .../Services/UserStore.cs | 136 +- src/docs/reference/modules/Users/README.md | 8 +- src/docs/releases/1.7.0.md | 4 + 44 files changed, 2947 insertions(+), 72 deletions(-) create mode 100644 src/OrchardCore.Modules/OrchardCore.Roles/ViewModels/RoleLoginSettingsViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Controllers/TwoFactorAuthenticationController.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Drivers/RoleLoginSettingsDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Filters/TwoFactorAuthenticationAuthorizationFilter.cs delete mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Models/LoginSettings.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Services/TwoFactorAuthenticationClaimsProvider.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/EnableAuthenticatorViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithRecoveryCodeViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithTwoFaViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/RoleLoginSettingsViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ShowRecoveryCodesViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/ViewModels/TwoFactorAuthenticationViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsEnableTwoFactorAuthentication.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsRoles.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsTwoFactorAuthentication.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Disable2FA.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/EnableAuthenticator.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/GenerateRecoveryCodes.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Index.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWith2FA.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWithRecoveryCode.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ResetAuthenticator.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ShowRecoveryCodes.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/package-lock.json create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/package.json create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/wwwroot/Scripts/qrcode.js create mode 100644 src/OrchardCore.Modules/OrchardCore.Users/wwwroot/Scripts/qrcode.min.js create mode 100644 src/OrchardCore/OrchardCore.Users.Core/Models/LoginSettings.cs create mode 100644 src/OrchardCore/OrchardCore.Users.Core/Services/LoginSettingsExtensions.cs diff --git a/src/OrchardCore.Modules/OrchardCore.Roles/ViewModels/RoleLoginSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Roles/ViewModels/RoleLoginSettingsViewModel.cs new file mode 100644 index 00000000000..25e3e4c04ad --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Roles/ViewModels/RoleLoginSettingsViewModel.cs @@ -0,0 +1,10 @@ +using System; + +namespace OrchardCore.Roles.ViewModels; + +public class RoleLoginSettingsViewModel +{ + public bool EnableTwoFactorAuthenticationForSpecificRoles { get; set; } + + public RoleEntry[] Roles { get; set; } = Array.Empty(); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Assets.json b/src/OrchardCore.Modules/OrchardCore.Users/Assets.json index efdc72128c4..69d516295dd 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Assets.json +++ b/src/OrchardCore.Modules/OrchardCore.Users/Assets.json @@ -4,5 +4,11 @@ "Assets/js/password-generator.js" ], "output": "wwwroot/Scripts/password-generator.js" + }, + { + "inputs": [ + "node_modules/qrcodejs/qrcode.js" + ], + "output": "wwwroot/Scripts/qrcode.js" } ] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs new file mode 100644 index 00000000000..d37d98b28ef --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountBaseController.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Users.Models; +using OrchardCore.Workflows.Services; + +namespace OrchardCore.Users.Controllers; + +public class AccountBaseController : Controller +{ + protected readonly UserManager _userManager; + + public AccountBaseController(UserManager userManager) + { + _userManager = userManager; + } + + protected async Task LoggedInActionResult(IUser user, string returnUrl = null, ExternalLoginInfo info = null) + { + var workflowManager = HttpContext.RequestServices.GetService(); + if (workflowManager != null && user is User u) + { + var input = new Dictionary + { + ["UserName"] = user.UserName, + ["ExternalClaims"] = info?.Principal?.GetSerializableClaims() ?? Enumerable.Empty(), + ["Roles"] = u.RoleNames, + ["Provider"] = info?.LoginProvider + }; + await workflowManager.TriggerEventAsync(nameof(Workflows.Activities.UserLoggedInEvent), + input: input, correlationId: u.UserId); + } + + return RedirectToLocal(returnUrl); + } + + protected IActionResult RedirectToLocal(string returnUrl) + { + if (Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl.ToUriComponents()); + } + + return Redirect("~/"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs index 4c593017b17..b119beae3f8 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/AccountController.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using OrchardCore.DisplayManagement.Notify; @@ -23,19 +22,17 @@ using OrchardCore.Users.Models; using OrchardCore.Users.Services; using OrchardCore.Users.ViewModels; -using IWorkflowManager = OrchardCore.Workflows.Services.IWorkflowManager; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; namespace OrchardCore.Users.Controllers { [Authorize] - public class AccountController : Controller + public class AccountController : AccountBaseController { public const string DefaultExternalLoginProtector = "DefaultExternalLogin"; private readonly IUserService _userService; private readonly SignInManager _signInManager; - private readonly UserManager _userManager; private readonly ILogger _logger; private readonly ISiteService _siteService; private readonly IEnumerable _accountEvents; @@ -61,9 +58,9 @@ public AccountController( IDistributedCache distributedCache, IDataProtectionProvider dataProtectionProvider, IEnumerable externalLoginHandlers) + : base(userManager) { _signInManager = signInManager; - _userManager = userManager; _userService = userService; _logger = logger; _siteService = siteService; @@ -219,6 +216,17 @@ public async Task Login(LoginViewModel model, string returnUrl = } } + if (result.RequiresTwoFactor) + { + return RedirectToAction(nameof(TwoFactorAuthenticationController.LoginWith2FA), + typeof(TwoFactorAuthenticationController).ControllerName(), + new + { + returnUrl, + model.RememberMe + }); + } + if (result.IsLockedOut) { ModelState.AddModelError(String.Empty, S["The account is locked out"]); @@ -298,35 +306,6 @@ private void AddIdentityErrors(IdentityResult result) } } - private IActionResult RedirectToLocal(string returnUrl) - { - if (Url.IsLocalUrl(returnUrl)) - { - return Redirect(returnUrl.ToUriComponents()); - } - - return Redirect("~/"); - } - - private async Task LoggedInActionResult(IUser user, string returnUrl = null, ExternalLoginInfo info = null) - { - var workflowManager = HttpContext.RequestServices.GetService(); - if (workflowManager != null && user is User u) - { - var input = new Dictionary - { - ["UserName"] = user.UserName, - ["ExternalClaims"] = info?.Principal?.GetSerializableClaims() ?? Enumerable.Empty(), - ["Roles"] = u.RoleNames, - ["Provider"] = info?.LoginProvider - }; - await workflowManager.TriggerEventAsync(nameof(Workflows.Activities.UserLoggedInEvent), - input: input, correlationId: u.UserId); - } - - return RedirectToLocal(returnUrl); - } - [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/TwoFactorAuthenticationController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/TwoFactorAuthenticationController.cs new file mode 100644 index 00000000000..74246bd2e98 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/TwoFactorAuthenticationController.cs @@ -0,0 +1,672 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Entities; +using OrchardCore.Environment.Shell; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Settings; +using OrchardCore.Users.Events; +using OrchardCore.Users.Models; +using OrchardCore.Users.Services; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Controllers; + +[Authorize] +public class TwoFactorAuthenticationController : AccountBaseController +{ + private const string _authenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&digits={3}&issuer={0}"; + + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + private readonly ISiteService _siteService; + private readonly IEnumerable _accountEvents; + private readonly INotifier _notifier; + private readonly IDistributedCache _distributedCache; + private readonly UrlEncoder _urlEncoder; + private readonly ShellSettings _shellSettings; + private readonly IHtmlLocalizer H; + private readonly IStringLocalizer S; + + public TwoFactorAuthenticationController( + SignInManager signInManager, + UserManager userManager, + ILogger logger, + ISiteService siteService, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer, + IEnumerable accountEvents, + INotifier notifier, + IDistributedCache distributedCache, + UrlEncoder urlEncoder, + ShellSettings shellSettings) + : base(userManager) + { + _signInManager = signInManager; + _logger = logger; + _siteService = siteService; + _accountEvents = accountEvents; + _notifier = notifier; + _distributedCache = distributedCache; + _urlEncoder = urlEncoder; + _shellSettings = shellSettings; + H = htmlLocalizer; + S = stringLocalizer; + } + + [HttpGet] + [AllowAnonymous] + public async Task LoginWith2FA(bool rememberMe, string returnUrl = null) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + // Ensure the user has gone through the username & password screen first. + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + + if (user == null) + { + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + var model = new LoginWithTwoFaViewModel() + { + RememberMe = rememberMe, + ReturnUrl = returnUrl, + AllowRememberClient = loginSettings.AllowRememberClientTwoFactorAuthentication, + }; + + return View(model); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWith2FA(LoginWithTwoFaViewModel model) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + model.AllowRememberClient = loginSettings.AllowRememberClientTwoFactorAuthentication; + + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + + if (user == null) + { + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + var authenticatorCode = StripToken(model.TwoFactorCode); + var rememberClient = loginSettings.AllowRememberClientTwoFactorAuthentication && model.RememberClient; + var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, model.RememberMe, rememberClient); + var userId = await _userManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); + + return await LoggedInActionResult(user, model.ReturnUrl); + } + + if (result.IsLockedOut) + { + _logger.LogWarning("User account locked out."); + ModelState.AddModelError(String.Empty, S["The account is locked out"]); + await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); + + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + ModelState.AddModelError(String.Empty, S["Invalid authenticator code."]); + + // Login failed with a known user. + await _accountEvents.InvokeAsync((e, user) => e.LoggingInFailedAsync(user), user, _logger); + + return View(model); + } + + [AllowAnonymous] + public async Task LoginWithRecoveryCode(string returnUrl = null) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user == null) + { + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + return View(new LoginWithRecoveryCodeViewModel() + { + ReturnUrl = returnUrl, + }); + } + + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWithRecoveryCode(LoginWithRecoveryCodeViewModel model) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + if (ModelState.IsValid) + { + // Ensure the user has gone through the username & password screen first. + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + + if (user == null) + { + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + var recoveryCode = model.RecoveryCode.Replace(" ", String.Empty); + + var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + + var userId = await _userManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + await _accountEvents.InvokeAsync((e, user) => e.LoggedInAsync(user), user, _logger); + + return await LoggedInActionResult(user, model.ReturnUrl); + } + + if (result.IsLockedOut) + { + _logger.LogWarning("User account locked out."); + + ModelState.AddModelError(String.Empty, S["The account is locked out"]); + await _accountEvents.InvokeAsync((e, user) => e.IsLockedOutAsync(user), user, _logger); + + return RedirectToAction(nameof(AccountController.Login), typeof(AccountController).ControllerName()); + } + + _logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); + ModelState.AddModelError(String.Empty, S["Invalid recovery code entered."]); + } + + return View(model); + } + + [Admin] + public async Task EnableAuthenticator(string returnUrl) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var model = await LoadSharedKeyAndQrCodeUriAsync(user, loginSettings); + + model.ReturnUrl = returnUrl; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Admin] + public async Task EnableAuthenticator(EnableAuthenticatorViewModel model) + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.EnableTwoFactorAuthentication && !loginSettings.EnableTwoFactorAuthenticationForSpecificRoles) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + return View(await LoadSharedKeyAndQrCodeUriAsync(user, loginSettings)); + } + + var verificationCode = StripToken(model.Code); + var provider = _userManager.Options.Tokens.AuthenticatorTokenProvider; + var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(user, provider, verificationCode); + + if (!is2faTokenValid) + { + ModelState.AddModelError(nameof(model.Code), S["Verification code is invalid."]); + + return View(await LoadSharedKeyAndQrCodeUriAsync(user, loginSettings)); + } + + await _userManager.SetTwoFactorEnabledAsync(user, true); + + var twoFactorClaim = User.Claims + .FirstOrDefault(claim => claim.Type == TwoFactorAuthenticationClaimsProvider.TwoFactorAuthenticationClaimType); + + if (twoFactorClaim != null) + { + await _userManager.RemoveClaimAsync(user, twoFactorClaim); + await _signInManager.RefreshSignInAsync(user); + } + + await _notifier.SuccessAsync(H["Your authenticator app has been verified."]); + + if (await _userManager.CountRecoveryCodesAsync(user) == 0) + { + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, loginSettings.NumberOfRecoveryCodesToGenerate); + await SetRecoveryCodes(recoveryCodes.ToArray(), await _userManager.GetUserIdAsync(user)); + + return RedirectToAction(nameof(ShowRecoveryCodes)); + } + + return RedirectToAction(nameof(Index)); + } + + [Admin] + public async Task Index() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var model = new TwoFactorAuthenticationViewModel() + { + HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null, + IsTwoFaEnabled = await _userManager.GetTwoFactorEnabledAsync(user), + IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user), + RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user), + CanDisableTwoFa = !loginSettings.RequireTwoFactorAuthentication + || !await loginSettings.CanEnableTwoFactorAuthenticationAsync(role => _userManager.IsInRoleAsync(user, role)), + }; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Admin] + public async Task ForgetTwoFactorClient() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _signInManager.ForgetTwoFactorClientAsync(); + await _notifier.SuccessAsync(H["The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code."]); + + return RedirectToAction(nameof(Index)); + } + + [Admin] + public async Task GenerateRecoveryCodes() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + if (!isTwoFactorEnabled) + { + await _notifier.ErrorAsync(H["Cannot generate recovery codes for user because they do not have 2FA enabled."]); + + return RedirectToAction(nameof(EnableAuthenticator)); + } + + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Admin] + [ActionName(nameof(GenerateRecoveryCodes))] + public async Task GenerateRecoveryCodesPost() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); + if (!isTwoFactorEnabled) + { + await _notifier.ErrorAsync(H["Cannot generate recovery codes for user because they do not have 2FA enabled."]); + + return RedirectToAction(nameof(EnableAuthenticator)); + } + + var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, loginSettings.NumberOfRecoveryCodesToGenerate); + await SetRecoveryCodes(recoveryCodes.ToArray(), await _userManager.GetUserIdAsync(user)); + + await _notifier.SuccessAsync(H["You have generated new recovery codes."]); + + return RedirectToAction(nameof(ShowRecoveryCodes)); + } + + [Admin] + public async Task ShowRecoveryCodes() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var userId = await _userManager.GetUserIdAsync(user); + + var recoveryCodes = await GetCachedRecoveryCodes(userId); + + if (recoveryCodes == null || recoveryCodes.Length == 0) + { + return RedirectToAction(nameof(Index)); + } + + return View(new ShowRecoveryCodesViewModel() + { + RecoveryCodes = recoveryCodes, + }); + } + + [Admin] + public async Task ResetAuthenticator() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Admin] + [ActionName(nameof(ResetAuthenticator))] + public async Task ResetAuthenticatorPost() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await _userManager.SetTwoFactorEnabledAsync(user, false); + await _userManager.ResetAuthenticatorKeyAsync(user); + await _signInManager.RefreshSignInAsync(user); + await _notifier.SuccessAsync(H["Your authenticator app key has been reset, you will need to configure your authenticator app using the new key."]); + + return RedirectToAction(nameof(EnableAuthenticator)); + } + + [Admin] + public async Task Disable2FA() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (loginSettings.RequireTwoFactorAuthentication + && await loginSettings.CanEnableTwoFactorAuthenticationAsync(role => _userManager.IsInRoleAsync(user, role))) + { + await _notifier.WarningAsync(H["Two-factor authentication cannot be disabled for the current user."]); + + return RedirectToAction(nameof(Index)); + } + + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Admin] + [ActionName(nameof(Disable2FA))] + public async Task Disable2FAPost() + { + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (!loginSettings.IsTwoFactorAuthenticationEnabled()) + { + return NotFound(); + } + + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (loginSettings.RequireTwoFactorAuthentication + && await loginSettings.CanEnableTwoFactorAuthenticationAsync(role => _userManager.IsInRoleAsync(user, role))) + { + await _notifier.WarningAsync(H["Two-factor authentication cannot be disabled for the current user."]); + + return RedirectToAction(nameof(Index)); + } + + var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + await _notifier.ErrorAsync(H["Unexpected error occurred disabling two-factor authentication."]); + } + else + { + await _notifier.WarningAsync(H["Two-factor authentication has been disabled. You can re-enable it when you setup an authenticator app"]); + } + + return RedirectToAction(nameof(Index)); + } + + private async Task SetRecoveryCodes(string[] codes, string userId) + { + var key = GetRecoveryCodesCacheKey(userId); + + var model = new ShowRecoveryCodesViewModel() + { + RecoveryCodes = codes ?? Array.Empty(), + }; + + var data = JsonSerializer.SerializeToUtf8Bytes(model); + + await _distributedCache.SetAsync(key, data, + new DistributedCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = new TimeSpan(0, 0, 5) + }); + } + + private async Task GetCachedRecoveryCodes(string userId) + { + var key = GetRecoveryCodesCacheKey(userId); + + var data = await _distributedCache.GetAsync(key); + + if (data != null && data.Length > 0) + { + var model = JsonSerializer.Deserialize(data); + + return model?.RecoveryCodes ?? Array.Empty(); + } + + return Array.Empty(); + } + + private async Task LoadSharedKeyAndQrCodeUriAsync(IUser user, LoginSettings settings) + { + // Load the authenticator key & QR code URI to display on the form. + var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + + if (String.IsNullOrEmpty(unformattedKey)) + { + await _userManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user); + } + + var displayName = await GetUserDisplayName(user, settings.UseEmailAsAuthenticatorDisplayName); + + return new EnableAuthenticatorViewModel() + { + SharedKey = FormatKey(unformattedKey), + AuthenticatorUri = await GenerateQrCodeUriAsync(displayName, unformattedKey, settings.TokenLength), + }; + } + + private Task GetUserDisplayName(IUser user, bool showEmail) + { + if (showEmail) + { + return _userManager.GetEmailAsync(user); + } + + return _userManager.GetUserNameAsync(user); + } + + private static string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + var currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' '); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private async Task GenerateQrCodeUriAsync(string displayName, string unformattedKey, int tokenLength) + { + var site = await _siteService.GetSiteSettingsAsync(); + + var issuer = String.IsNullOrWhiteSpace(site.SiteName) ? _shellSettings.Name : site.SiteName.Trim(); + + return String.Format( + CultureInfo.InvariantCulture, + _authenticatorUriFormat, + _urlEncoder.Encode(issuer), + _urlEncoder.Encode(displayName), + unformattedKey, + tokenLength); + } + + private static string StripToken(string code) => + code.Replace(" ", String.Empty).Replace("-", String.Empty); + + private static string GetRecoveryCodesCacheKey(string userId) + => $"TwoFactorAuthenticationRecoveryCodes_{userId}"; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs index 3bd834be6da..5bb9d7c9b94 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/LoginSettingsDisplayDriver.cs @@ -2,9 +2,12 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Security.Services; using OrchardCore.Settings; using OrchardCore.Users.Models; @@ -15,13 +18,17 @@ public class LoginSettingsDisplayDriver : SectionDisplayDriver stringLocalizer, + IRoleService roleService) { _httpContextAccessor = httpContextAccessor; _authorizationService = authorizationService; + S = stringLocalizer; } public override async Task EditAsync(LoginSettings settings, BuildEditorContext context) { @@ -32,7 +39,7 @@ public override async Task EditAsync(LoginSettings settings, Bui return null; } - return Initialize("LoginSettings_Edit", model => + var contentResult = Initialize("LoginSettings_Edit", model => { model.UseSiteTheme = settings.UseSiteTheme; model.UseExternalProviderIfOnlyOneDefined = settings.UseExternalProviderIfOnlyOneDefined; @@ -41,20 +48,53 @@ public override async Task EditAsync(LoginSettings settings, Bui model.SyncRolesScript = settings.SyncRolesScript; model.AllowChangingEmail = settings.AllowChangingEmail; model.AllowChangingUsername = settings.AllowChangingUsername; - }).Location("Content:5").OnGroup(GroupId); + }).Location("Content:5#General") + .OnGroup(GroupId); + + var enableTwoFaResult = Initialize("LoginSettingsEnableTwoFactorAuthentication_Edit", model => + { + model.EnableTwoFactorAuthentication = settings.EnableTwoFactorAuthentication; + }).Location("Content:5#Two-factor Authentication") + .OnGroup(GroupId); + + var twoFaResult = Initialize("LoginSettingsTwoFactorAuthentication_Edit", model => + { + model.EnableTwoFactorAuthentication = settings.EnableTwoFactorAuthentication; + model.NumberOfRecoveryCodesToGenerate = settings.NumberOfRecoveryCodesToGenerate; + model.UseEmailAsAuthenticatorDisplayName = settings.UseEmailAsAuthenticatorDisplayName; + model.RequireTwoFactorAuthentication = settings.RequireTwoFactorAuthentication; + model.AllowRememberClientTwoFactorAuthentication = settings.AllowRememberClientTwoFactorAuthentication; + model.TokenLength = settings.TokenLength; + }).Location("Content:10#Two-factor Authentication") + .OnGroup(GroupId); + + return Combine(contentResult, enableTwoFaResult, twoFaResult); } public override async Task UpdateAsync(LoginSettings section, BuildEditorContext context) { - if (!await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, CommonPermissions.ManageUsers)) + if (!context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase) + || !await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, CommonPermissions.ManageUsers)) { return null; } - if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) + await context.Updater.TryUpdateModelAsync(section, Prefix); + + if (section.NumberOfRecoveryCodesToGenerate < 1) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(section.NumberOfRecoveryCodesToGenerate), S["Number of Recovery Codes to Generate should be grater than 0."]); + } + + // A possible issue in Identity prevents from validation token that are not 6 in length. + // If this limitation is lifted, the following block can be uncommented. + // For more info read https://github.com/dotnet/aspnetcore/issues/48317 + /* + if (section.TokenLength != 6 && section.TokenLength != 8) { - await context.Updater.TryUpdateModelAsync(section, Prefix); + context.Updater.ModelState.AddModelError(Prefix, nameof(section.TokenLength), S["The token length should be either 6 or 8."]); } + */ return await EditAsync(section, context); } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RoleLoginSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RoleLoginSettingsDisplayDriver.cs new file mode 100644 index 00000000000..dfedbe82515 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Drivers/RoleLoginSettingsDisplayDriver.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Security.Services; +using OrchardCore.Settings; +using OrchardCore.Users.Models; +using OrchardCore.Users.ViewModels; + +namespace OrchardCore.Users.Drivers; + +public class RoleLoginSettingsDisplayDriver : SectionDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IRoleService _roleService; + private readonly IStringLocalizer S; + + public RoleLoginSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IRoleService roleService, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _roleService = roleService; + S = stringLocalizer; + } + + public override async Task EditAsync(LoginSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, CommonPermissions.ManageUsers)) + { + return null; + } + + return Initialize("LoginSettingsRoles_Edit", async model => + { + model.EnableTwoFactorAuthentication = settings.EnableTwoFactorAuthentication; + model.EnableTwoFactorAuthenticationForSpecificRoles = settings.EnableTwoFactorAuthenticationForSpecificRoles; + var roles = await _roleService.GetRolesAsync(); + model.Roles = roles.Select(role => new RoleEntry() + { + Role = role.RoleName, + IsSelected = settings.Roles != null && settings.Roles.Contains(role.RoleName), + }).OrderBy(entry => entry.Role) + .ToArray(); + }).Location("Content:5.1#Two-factor Authentication") + .OnGroup(LoginSettingsDisplayDriver.GroupId); + } + + public override async Task UpdateAsync(LoginSettings section, BuildEditorContext context) + { + if (!context.GroupId.Equals(LoginSettingsDisplayDriver.GroupId, StringComparison.OrdinalIgnoreCase) + || !await _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, CommonPermissions.ManageUsers)) + { + return null; + } + + var model = new RoleLoginSettingsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (model.EnableTwoFactorAuthenticationForSpecificRoles + && (model.Roles == null || !model.Roles.Any(x => x.IsSelected))) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Roles), S["Select at least one role."]); + } + + if (model.EnableTwoFactorAuthenticationForSpecificRoles) + { + section.EnableTwoFactorAuthenticationForSpecificRoles = true; + section.Roles = model.Roles.Where(x => x.IsSelected) + .Select(x => x.Role) + .ToArray(); + } + else + { + section.EnableTwoFactorAuthenticationForSpecificRoles = false; + } + + return await EditAsync(section, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Filters/TwoFactorAuthenticationAuthorizationFilter.cs b/src/OrchardCore.Modules/OrchardCore.Users/Filters/TwoFactorAuthenticationAuthorizationFilter.cs new file mode 100644 index 00000000000..3c797abc7a1 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Filters/TwoFactorAuthenticationAuthorizationFilter.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.Entities; +using OrchardCore.Settings; +using OrchardCore.Users.Models; +using OrchardCore.Users.Services; + +namespace OrchardCore.Users.Filters; + +public class TwoFactorAuthenticationAuthorizationFilter : IAsyncAuthorizationFilter +{ + private readonly UserOptions _userOptions; + + public TwoFactorAuthenticationAuthorizationFilter(IOptions userOptions) + { + _userOptions = userOptions.Value; + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!(context.HttpContext?.User?.Identity?.IsAuthenticated ?? false) + || context.HttpContext.Request.Path.Equals("/" + _userOptions.LogoffPath, StringComparison.OrdinalIgnoreCase) + || context.HttpContext.Request.Path.Equals("/" + _userOptions.EnableAuthenticatorPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var siteService = context.HttpContext.RequestServices.GetService(); + + if (siteService == null) + { + return; + } + + var loginSettings = (await siteService.GetSiteSettingsAsync()).As(); + + if (loginSettings.RequireTwoFactorAuthentication + && loginSettings.IsTwoFactorAuthenticationEnabled() + && context.HttpContext.User.HasClaim(claim => claim.Type == TwoFactorAuthenticationClaimsProvider.TwoFactorAuthenticationClaimType)) + { + context.Result = new RedirectResult("~/" + _userOptions.EnableAuthenticatorPath); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Models/LoginSettings.cs b/src/OrchardCore.Modules/OrchardCore.Users/Models/LoginSettings.cs deleted file mode 100644 index 9547ea1456a..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Users/Models/LoginSettings.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace OrchardCore.Users.Models -{ - public class LoginSettings - { - public bool UseSiteTheme { get; set; } - - public bool UseExternalProviderIfOnlyOneDefined { get; set; } - - public bool DisableLocalLogin { get; set; } - - public bool UseScriptToSyncRoles { get; set; } - - public string SyncRolesScript { get; set; } - - public bool AllowChangingUsername { get; set; } - - public bool AllowChangingEmail { get; set; } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Services/TwoFactorAuthenticationClaimsProvider.cs b/src/OrchardCore.Modules/OrchardCore.Users/Services/TwoFactorAuthenticationClaimsProvider.cs new file mode 100644 index 00000000000..0fad5b82e49 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Services/TwoFactorAuthenticationClaimsProvider.cs @@ -0,0 +1,48 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using OrchardCore.Entities; +using OrchardCore.Settings; +using OrchardCore.Users.Models; + +namespace OrchardCore.Users.Services; + +public class TwoFactorAuthenticationClaimsProvider : IUserClaimsProvider +{ + public const string TwoFactorAuthenticationClaimType = "TwoFacAuth"; + + private readonly UserManager _userManager; + private readonly ISiteService _siteService; + + public TwoFactorAuthenticationClaimsProvider( + UserManager userManager, + ISiteService siteService) + { + _userManager = userManager; + _siteService = siteService; + } + + public async Task GenerateAsync(IUser user, ClaimsIdentity claims) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (claims == null) + { + throw new ArgumentNullException(nameof(claims)); + } + + var loginSettings = (await _siteService.GetSiteSettingsAsync()).As(); + + if (loginSettings.RequireTwoFactorAuthentication + && !await _userManager.GetTwoFactorEnabledAsync(user) + && await loginSettings.CanEnableTwoFactorAuthenticationAsync(role => _userManager.IsInRoleAsync(user, role))) + { + // At this point, we know that the user must enable two-factor authentication. + claims.AddClaim(new Claim(TwoFactorAuthenticationClaimType, "required")); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs b/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs index 4965434860e..95511c1db94 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Services/UsersThemeSelector.cs @@ -45,6 +45,11 @@ public async Task GetThemeAsync() case "Account": useSiteTheme = (await _siteService.GetSiteSettingsAsync()).As().UseSiteTheme; break; + case "TwoFactorAuthentication": + useSiteTheme = routeValues["action"] != null + && routeValues["action"].ToString().StartsWith("LoginWith", StringComparison.OrdinalIgnoreCase) + && (await _siteService.GetSiteSettingsAsync()).As().UseSiteTheme; + break; case "Registration": useSiteTheme = (await _siteService.GetSiteSettingsAsync()).As().UseSiteTheme; break; diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs index f021b5497e0..139bbebb558 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Startup.cs @@ -36,6 +36,7 @@ using OrchardCore.Users.Commands; using OrchardCore.Users.Controllers; using OrchardCore.Users.Drivers; +using OrchardCore.Users.Filters; using OrchardCore.Users.Handlers; using OrchardCore.Users.Indexes; using OrchardCore.Users.Liquid; @@ -141,6 +142,58 @@ public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilde pattern: _adminOptions.AdminUrlPrefix + "/Users/Display/{id}", defaults: new { controller = adminControllerName, action = nameof(AdminController.Display) } ); + + var twoFaControllerName = typeof(TwoFactorAuthenticationController).ControllerName(); + + routes.MapAreaControllerRoute( + name: "LoginWith2fa", + areaName: "OrchardCore.Users", + pattern: "LoginWith2fa", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.LoginWith2FA) } + ); + routes.MapAreaControllerRoute( + name: "EnableAuthenticator", + areaName: "OrchardCore.Users", + pattern: userOptions.EnableAuthenticatorPath, + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.EnableAuthenticator) } + ); + routes.MapAreaControllerRoute( + name: "TwoFactorAuthentication", + areaName: "OrchardCore.Users", + pattern: "TwoFactorAuthentication", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.Index) } + ); + routes.MapAreaControllerRoute( + name: "GenerateRecoveryCodes", + areaName: "OrchardCore.Users", + pattern: "GenerateRecoveryCodes", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.GenerateRecoveryCodes) } + ); + routes.MapAreaControllerRoute( + name: "ShowRecoveryCodes", + areaName: "OrchardCore.Users", + pattern: "ShowRecoveryCodes", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.ShowRecoveryCodes) } + ); + routes.MapAreaControllerRoute( + name: "ResetAuthenticator", + areaName: "OrchardCore.Users", + pattern: "ResetAuthenticator", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.ResetAuthenticator) } + ); + routes.MapAreaControllerRoute( + name: "Disable2FA", + areaName: "OrchardCore.Users", + pattern: "Disable2FA", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.Disable2FA) } + ); + routes.MapAreaControllerRoute( + name: "LoginWithRecoveryCode", + areaName: "OrchardCore.Users", + pattern: "LoginWithRecoveryCode", + defaults: new { controller = twoFaControllerName, action = nameof(TwoFactorAuthenticationController.LoginWithRecoveryCode) } + ); + builder.UseAuthorization(); } @@ -155,17 +208,31 @@ public override void ConfigureServices(IServiceCollection services) // Add ILookupNormalizer as Singleton because it is needed by UserIndexProvider services.TryAddSingleton(); - // Adds the default token providers used to generate tokens for reset passwords, change email - // and change telephone number operations, and for two factor authentication token generation. - services.AddIdentity(options => + // Add the default token providers used to generate tokens for reset passwords, change email, + // and for two-factor authentication token generation. + var identityBuilder = services.AddIdentity(options => { // Specify OrchardCore User requirements. // A user name cannot include an @ symbol, i.e. be an email address // An email address must be provided, and be unique. options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._+"; options.User.RequireUniqueEmail = true; - }) - .AddDefaultTokenProviders(); + }); + + var dataProtectionProviderType = typeof(DataProtectorTokenProvider<>).MakeGenericType(identityBuilder.UserType); + //var phoneNumberProviderType = typeof(PhoneNumberTokenProvider<>).MakeGenericType(identityBuilder.UserType); + var emailTokenProviderType = typeof(EmailTokenProvider<>).MakeGenericType(identityBuilder.UserType); + var authenticatorProviderType = typeof(AuthenticatorTokenProvider<>).MakeGenericType(identityBuilder.UserType); + + identityBuilder.AddTokenProvider(TokenOptions.DefaultProvider, dataProtectionProviderType) + .AddTokenProvider(TokenOptions.DefaultEmailProvider, emailTokenProviderType) + // .AddTokenProvider(TokenOptions.DefaultPhoneProvider, phoneNumberProviderType) + .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider, authenticatorProviderType); + + services.AddMvc(options => + { + options.Filters.Add(); + }); // Configure the authentication options to use the application cookie scheme as the default sign-out handler. // This is required for security modules like the OpenID module (that uses SignOutAsync()) to work correctly. @@ -190,6 +257,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddDataMigration(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -245,6 +313,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddScoped, RoleLoginSettingsDisplayDriver>(); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/UserOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Users/UserOptionsConfiguration.cs index 7d1b744a194..dfeffe4108e 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/UserOptionsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/UserOptionsConfiguration.cs @@ -15,6 +15,11 @@ static UserOptionsConfiguration() .DefineScript("password-generator") .SetUrl("~/OrchardCore.Users/Scripts/password-generator.min.js", "~/OrchardCore.Users/Scripts/password-generator.js") .SetVersion("1.0.0"); + + _manifest + .DefineScript("qrcode") + .SetUrl("~/OrchardCore.Users/Scripts/qrcode.min.js", "~/OrchardCore.Users/Scripts/qrcode.js") + .SetVersion("1.0.0"); } public void Configure(ResourceManagementOptions options) diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/EnableAuthenticatorViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/EnableAuthenticatorViewModel.cs new file mode 100644 index 00000000000..8fddbd76a41 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/EnableAuthenticatorViewModel.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Users.ViewModels; + +public class EnableAuthenticatorViewModel +{ + public string SharedKey { get; set; } + + public string AuthenticatorUri { get; set; } + + public string ReturnUrl { get; set; } + + [Required] + public string Code { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithRecoveryCodeViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithRecoveryCodeViewModel.cs new file mode 100644 index 00000000000..87331e96406 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithRecoveryCodeViewModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Users.ViewModels; + +public class LoginWithRecoveryCodeViewModel +{ + [Required] + public string RecoveryCode { get; set; } + + public string ReturnUrl { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithTwoFaViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithTwoFaViewModel.cs new file mode 100644 index 00000000000..54a35046aee --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/LoginWithTwoFaViewModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace OrchardCore.Users.ViewModels; + +public class LoginWithTwoFaViewModel +{ + public bool RememberMe { get; set; } + + public string ReturnUrl { get; set; } + + [Required] + public string TwoFactorCode { get; set; } + + public bool RememberClient { get; set; } + + [BindNever] + public bool AllowRememberClient { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/RoleLoginSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/RoleLoginSettingsViewModel.cs new file mode 100644 index 00000000000..04a8496ade9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/RoleLoginSettingsViewModel.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace OrchardCore.Users.ViewModels; + +public class RoleLoginSettingsViewModel +{ + [BindNever] + public bool EnableTwoFactorAuthentication { get; set; } + + public bool EnableTwoFactorAuthenticationForSpecificRoles { get; set; } + + public RoleEntry[] Roles { get; set; } = Array.Empty(); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ShowRecoveryCodesViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ShowRecoveryCodesViewModel.cs new file mode 100644 index 00000000000..4e25a10a11e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/ShowRecoveryCodesViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Users.ViewModels; + +public class ShowRecoveryCodesViewModel +{ + public string[] RecoveryCodes { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/TwoFactorAuthenticationViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/TwoFactorAuthenticationViewModel.cs new file mode 100644 index 00000000000..4743f63156d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/ViewModels/TwoFactorAuthenticationViewModel.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace OrchardCore.Users.ViewModels; + +public class TwoFactorAuthenticationViewModel +{ + public bool HasAuthenticator { get; set; } + + public bool IsTwoFaEnabled { get; set; } + + public bool IsMachineRemembered { get; set; } + + public int RecoveryCodesLeft { get; set; } + + [BindNever] + public bool CanDisableTwoFa { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsEnableTwoFactorAuthentication.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsEnableTwoFactorAuthentication.Edit.cshtml new file mode 100644 index 00000000000..9f8f8dffcb4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsEnableTwoFactorAuthentication.Edit.cshtml @@ -0,0 +1,9 @@ +@model OrchardCore.Users.Models.LoginSettings + +
+
+ + + +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsRoles.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsRoles.Edit.cshtml new file mode 100644 index 00000000000..5f3e03047d2 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsRoles.Edit.cshtml @@ -0,0 +1,24 @@ +@model OrchardCore.Users.ViewModels.RoleLoginSettingsViewModel + +
+
+
+ + + +
+
+ +
+ @T["Select the roles to enable two-factor authentication for."] + + @for (var i = 0; i < Model.Roles.Length; i++) + { +
+ + + +
+ } +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsTwoFactorAuthentication.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsTwoFactorAuthentication.Edit.cshtml new file mode 100644 index 00000000000..883bd61f37a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/LoginSettingsTwoFactorAuthentication.Edit.cshtml @@ -0,0 +1,51 @@ +@model OrchardCore.Users.Models.LoginSettings + +
+
+ + + + @T["When selected, users may use Remember Client during login to avoid having to provide a token every time."] +
+
+ +
+
+ + + + @T["When selected, the users will see their email address in the authenticatior app. Otherwise, the username will be displayed."] +
+
+ +
+
+ + + + @T["When selected, any user with enabled two-factor authentication authentication will be required to use it."] +
+
+ +@* + +// A possible issue in Identity prevents from validation token that are not 6 in length. +// If this limitation is lifted, the following line can be removed. +// For more info read https://github.com/dotnet/aspnetcore/issues/48317 + +
+ + + + @T["The length token length that the authenticator app should generate. Default is 6."] +
+*@ +
+ + + + @T["The number of recovery codes to generate. Default is 5."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Disable2FA.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Disable2FA.cshtml new file mode 100644 index 00000000000..7486f788cb3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Disable2FA.cshtml @@ -0,0 +1,17 @@ + +

@RenderTitleSegments(T["Disable two-factor authentication"])

+ + + +
+
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/EnableAuthenticator.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/EnableAuthenticator.cshtml new file mode 100644 index 00000000000..611101bdd08 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/EnableAuthenticator.cshtml @@ -0,0 +1,63 @@ +@using OrchardCore.Users.Services +@model EnableAuthenticatorViewModel + +

@RenderTitleSegments(T["Configure Authenticator App"])

+ +@if (User.HasClaim(claim => claim.Type == TwoFactorAuthenticationClaimsProvider.TwoFactorAuthenticationClaimType)) +{ +
+ @T["Two-factor authentication must be enabled before proceeding."] +
+} + +
+

@T["To use an authenticator app go through the following steps:"]

+
    +
  1. +

    + @T["Download a two-factor authenticator app like Microsoft Authenticator for Android and iOS.","https://go.microsoft.com/fwlink/?Linkid=825072","https://go.microsoft.com/fwlink/?Linkid=825073"] + + @T["Or, Google Authenticator for Android and iOS.","https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en", "https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8"] +

    +
  2. +
  3. +

    + @T["Scan the QR Code below, or enter this key {0} into your two-factor authenticator app. You may ignore spaces and casing.", $"{Model.SharedKey}"] +

    +
    +
    +
  4. +
  5. +

    + @T["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. +
+
+ + + diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/GenerateRecoveryCodes.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/GenerateRecoveryCodes.cshtml new file mode 100644 index 00000000000..6eb37a8502f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/GenerateRecoveryCodes.cshtml @@ -0,0 +1,12 @@ +

@RenderTitleSegments(T["Generate two-factor authentication (2FA) recovery codes"])

+ + +
+
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Index.cshtml new file mode 100644 index 00000000000..03b7b62bf10 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/Index.cshtml @@ -0,0 +1,82 @@ +@using Microsoft.AspNetCore.Http.Features + +@model TwoFactorAuthenticationViewModel + +@{ + var canTrack = ViewContext.HttpContext.Features.Get()?.CanTrack ?? true; +} + +

@RenderTitleSegments(T["Two-factor Authentication"])

+ +@if (canTrack) +{ +
+ @if (Model.IsTwoFaEnabled) + { +

@T["Two-factor authentication is enabled."]

+ } + else + { +

@T["Two-factor authentication is not enabled yet."]

+ } + + @if (Model.IsTwoFaEnabled) + { + if (Model.RecoveryCodesLeft == 0) + { +
+ @T["You have no recovery codes left."] +

@T["You must generate a new set of recovery codes before you can log in with a recovery code.", Url.Action("GenerateRecoveryCodes")]

+
+ } + else if (Model.RecoveryCodesLeft == 1) + { +
+ @T["You have 1 recovery code left."] +

@T["You can generate a new set of recovery codes.", Url.Action("GenerateRecoveryCodes")]

+
+ } + else if (Model.RecoveryCodesLeft <= 3) + { +
+ @T["You have {0} recovery {1} left.", Model.RecoveryCodesLeft, T.Plural(Model.RecoveryCodesLeft, "code", "codes")]) +

@T["You should generate a new set of recovery codes.", Url.Action("GenerateRecoveryCodes")]

+
+ } + + @if (Model.IsMachineRemembered) + { +
+ +
+ } + + @if (Model.CanDisableTwoFa) + { + @T["Disable two-factor authentication"] + } + + @T["Reset recovery codes"] + } +
+ +
+

@T["Authenticator App"]

+ @if (!Model.HasAuthenticator) + { + @T["Add authenticator app"] + } + else + { + @T["Set up authenticator app"] + @T["Reset authenticator app"] + } +
+} +else +{ +
+ @T["Privacy and cookie policy have not been accepted."] +

@T["You must accept the policy before you can enable two-factor authentication."]

+
+} diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWith2FA.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWith2FA.cshtml new file mode 100644 index 00000000000..d057229663e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWith2FA.cshtml @@ -0,0 +1,61 @@ +@using OrchardCore.Settings +@using OrchardCore.Users.Models +@using OrchardCore.Entities + +@model LoginWithTwoFaViewModel + +@inject ISiteService SiteService +@{ + ViewLayout = "Layout__Login"; +} + +

@RenderTitleSegments(T["Two-factor authentication"])

+ +
+ +

@T["Your login is protected with a second authentication code. Enter your authenticator code below."]

+ +
+ + @if (!ViewContext.ModelState.IsValid) + { +
+ +
+ } + +
+ +
+ +
+ + + +
+ @if (Model.AllowRememberClient) + { +
+
+ + +
+
+ } +
+ +
+
+
+
+

+ @{ + var url = Url.ActionLink("LoginWithRecoveryCode", controller: null, new + { + returnUrl = Model.ReturnUrl + }); + } + + @T["Don't have access to your authenticator device? You can log in with a recovery code.", url] +

+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWithRecoveryCode.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWithRecoveryCode.cshtml new file mode 100644 index 00000000000..72f518f4601 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/LoginWithRecoveryCode.cshtml @@ -0,0 +1,33 @@ +@model LoginWithRecoveryCodeViewModel + +@{ + ViewLayout = "Layout__Login"; +} + +

@RenderTitleSegments(T["Recovery code verification"])

+ +
+

+ @T["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."] +

+
+ + @if (!ViewContext.ModelState.IsValid) + { +
+ +
+ } + +
+
+
+ + + +
+ +
+
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ResetAuthenticator.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ResetAuthenticator.cshtml new file mode 100644 index 00000000000..a84bba03d5d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ResetAuthenticator.cshtml @@ -0,0 +1,13 @@ + +

@RenderTitleSegments(T["Reset authenticator key"])

+ + + +
+
+ +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ShowRecoveryCodes.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ShowRecoveryCodes.cshtml new file mode 100644 index 00000000000..9458bedac4e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/TwoFactorAuthentication/ShowRecoveryCodes.cshtml @@ -0,0 +1,18 @@ +@model ShowRecoveryCodesViewModel + +

@RenderTitleSegments(T["Recovery codes"])

+ + + +
+
+

@T["Any of the following codes can be used to login."]

+ @foreach (var line in Model.RecoveryCodes) + { +

@line

+ } +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenu.cshtml b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenu.cshtml index 55d386f991f..6ad9c55d091 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenu.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Users/Views/UserMenu.cshtml @@ -1,7 +1,13 @@ @using System.Security.Claims +@using OrchardCore.Settings +@using OrchardCore.Users.Models +@using OrchardCore.Entities + +@inject ISiteService SiteService @inject IAuthorizationService AuthorizationService @{ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var loginSettings = (await SiteService.GetSiteSettingsAsync()).As(); }