698 lines
28 KiB
C#
698 lines
28 KiB
C#
using System.Globalization;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Umbraco.Cms.Core;
|
|
using Umbraco.Cms.Core.Configuration.Models;
|
|
using Umbraco.Cms.Core.Hosting;
|
|
using Umbraco.Cms.Core.Mail;
|
|
using Umbraco.Cms.Core.Mapping;
|
|
using Umbraco.Cms.Core.Models;
|
|
using Umbraco.Cms.Core.Models.ContentEditing;
|
|
using Umbraco.Cms.Core.Models.Email;
|
|
using Umbraco.Cms.Core.Models.Membership;
|
|
using Umbraco.Cms.Core.Net;
|
|
using Umbraco.Cms.Core.Security;
|
|
using Umbraco.Cms.Core.Services;
|
|
using Umbraco.Cms.Infrastructure.Security;
|
|
using Umbraco.Cms.Web.BackOffice.Filters;
|
|
using Umbraco.Cms.Web.BackOffice.Security;
|
|
using Umbraco.Cms.Web.Common.ActionsResults;
|
|
using Umbraco.Cms.Web.Common.Attributes;
|
|
using Umbraco.Cms.Web.Common.Authorization;
|
|
using Umbraco.Cms.Web.Common.Controllers;
|
|
using Umbraco.Cms.Web.Common.Filters;
|
|
using Umbraco.Cms.Web.Common.Models;
|
|
using Umbraco.Extensions;
|
|
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
|
|
|
|
namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
|
// See
|
|
// for a bigger example of this type of controller implementation in netcore:
|
|
// https://github.com/dotnet/AspNetCore.Docs/blob/2efb4554f8f659be97ee7cd5dd6143b871b330a5/aspnetcore/migration/1x-to-2x/samples/AspNetCoreDotNetCore2App/AspNetCoreDotNetCore2App/Controllers/AccountController.cs
|
|
// https://github.com/dotnet/AspNetCore.Docs/blob/ad16f5e1da6c04fa4996ee67b513f2a90fa0d712/aspnetcore/common/samples/WebApplication1/Controllers/AccountController.cs
|
|
// with authenticator app
|
|
// https://github.com/dotnet/AspNetCore.Docs/blob/master/aspnetcore/security/authentication/identity/sample/src/ASPNETCore-IdentityDemoComplete/IdentityDemo/Controllers/AccountController.cs
|
|
|
|
[PluginController(Constants.Web.Mvc
|
|
.BackOfficeApiArea)] // TODO: Maybe this could be applied with our Application Model conventions
|
|
//[ValidationFilter] // TODO: I don't actually think this is required with our custom Application Model conventions applied
|
|
[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions
|
|
[IsBackOffice]
|
|
[DisableBrowserCache]
|
|
public class AuthenticationController : UmbracoApiControllerBase
|
|
{
|
|
// NOTE: Each action must either be explicitly authorized or explicitly [AllowAnonymous], the latter is optional because
|
|
// this controller itself doesn't require authz but it's more clear what the intention is.
|
|
|
|
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
|
|
private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions;
|
|
private readonly IEmailSender _emailSender;
|
|
private readonly IBackOfficeExternalLoginProviders _externalAuthenticationOptions;
|
|
private readonly GlobalSettings _globalSettings;
|
|
private readonly IHostingEnvironment _hostingEnvironment;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly IIpResolver _ipResolver;
|
|
private readonly LinkGenerator _linkGenerator;
|
|
private readonly ILogger<AuthenticationController> _logger;
|
|
private readonly UserPasswordConfigurationSettings _passwordConfiguration;
|
|
private readonly SecuritySettings _securitySettings;
|
|
private readonly IBackOfficeSignInManager _signInManager;
|
|
private readonly ISmsSender _smsSender;
|
|
private readonly ILocalizedTextService _textService;
|
|
private readonly ITwoFactorLoginService _twoFactorLoginService;
|
|
private readonly IUmbracoMapper _umbracoMapper;
|
|
private readonly IBackOfficeUserManager _userManager;
|
|
private readonly IUserService _userService;
|
|
private readonly WebRoutingSettings _webRoutingSettings;
|
|
|
|
// TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
|
|
[ActivatorUtilitiesConstructor]
|
|
public AuthenticationController(
|
|
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
|
IBackOfficeUserManager backOfficeUserManager,
|
|
IBackOfficeSignInManager signInManager,
|
|
IUserService userService,
|
|
ILocalizedTextService textService,
|
|
IUmbracoMapper umbracoMapper,
|
|
IOptionsSnapshot<GlobalSettings> globalSettings,
|
|
IOptionsSnapshot<SecuritySettings> securitySettings,
|
|
ILogger<AuthenticationController> logger,
|
|
IIpResolver ipResolver,
|
|
IOptionsSnapshot<UserPasswordConfigurationSettings> passwordConfiguration,
|
|
IEmailSender emailSender,
|
|
ISmsSender smsSender,
|
|
IHostingEnvironment hostingEnvironment,
|
|
LinkGenerator linkGenerator,
|
|
IBackOfficeExternalLoginProviders externalAuthenticationOptions,
|
|
IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IOptions<WebRoutingSettings> webRoutingSettings,
|
|
ITwoFactorLoginService twoFactorLoginService)
|
|
{
|
|
_backofficeSecurityAccessor = backofficeSecurityAccessor;
|
|
_userManager = backOfficeUserManager;
|
|
_signInManager = signInManager;
|
|
_userService = userService;
|
|
_textService = textService;
|
|
_umbracoMapper = umbracoMapper;
|
|
_globalSettings = globalSettings.Value;
|
|
_securitySettings = securitySettings.Value;
|
|
_logger = logger;
|
|
_ipResolver = ipResolver;
|
|
_passwordConfiguration = passwordConfiguration.Value;
|
|
_emailSender = emailSender;
|
|
_smsSender = smsSender;
|
|
_hostingEnvironment = hostingEnvironment;
|
|
_linkGenerator = linkGenerator;
|
|
_externalAuthenticationOptions = externalAuthenticationOptions;
|
|
_backOfficeTwoFactorOptions = backOfficeTwoFactorOptions;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_webRoutingSettings = webRoutingSettings.Value;
|
|
_twoFactorLoginService = twoFactorLoginService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the configuration for the backoffice user membership provider - used to configure the change password
|
|
/// dialog
|
|
/// </summary>
|
|
[AllowAnonymous] // Needed for users that are invited when they use the link from the mail they are not authorized
|
|
[Authorize(Policy =
|
|
AuthorizationPolicies.BackOfficeAccess)] // Needed to enforce the principle set on the request, if one exists.
|
|
public IDictionary<string, object> GetPasswordConfig(int userId)
|
|
{
|
|
Attempt<int> currentUserId =
|
|
_backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId() ?? Attempt<int>.Fail();
|
|
return _passwordConfiguration.GetConfiguration(
|
|
currentUserId.Success
|
|
? currentUserId.Result != userId
|
|
: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object
|
|
/// </summary>
|
|
/// <param name="id"></param>
|
|
/// <param name="token"></param>
|
|
/// <returns></returns>
|
|
/// <remarks>
|
|
/// This will also update the security stamp for the user so it can only be used once
|
|
/// </remarks>
|
|
[ValidateAngularAntiForgeryToken]
|
|
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
|
|
public async Task<ActionResult<UserDisplay?>> PostVerifyInvite([FromQuery] int id, [FromQuery] string token)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var decoded = token.FromUrlBase64();
|
|
if (decoded.IsNullOrWhiteSpace())
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
BackOfficeIdentityUser? identityUser = await _userManager.FindByIdAsync(id.ToString());
|
|
if (identityUser == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
IdentityResult result = await _userManager.ConfirmEmailAsync(identityUser, decoded!);
|
|
|
|
if (result.Succeeded == false)
|
|
{
|
|
return ValidationErrorResult.CreateNotificationValidationErrorResult(result.Errors.ToErrorMessage());
|
|
}
|
|
|
|
await _signInManager.SignOutAsync();
|
|
|
|
await _signInManager.SignInAsync(identityUser, false);
|
|
|
|
IUser? user = _userService.GetUserById(id);
|
|
|
|
return _umbracoMapper.Map<UserDisplay>(user);
|
|
}
|
|
|
|
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
|
|
[ValidateAngularAntiForgeryToken]
|
|
public async Task<IActionResult> PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel)
|
|
{
|
|
var userId = User.Identity?.GetUserId();
|
|
if (userId is null)
|
|
{
|
|
throw new InvalidOperationException("Could not find userId");
|
|
}
|
|
var user = await _userManager.FindByIdAsync(userId);
|
|
if (user == null) throw new InvalidOperationException("Could not find user");
|
|
|
|
AuthenticationScheme? authType = (await _signInManager.GetExternalAuthenticationSchemesAsync())
|
|
.FirstOrDefault(x => x.Name == unlinkLoginModel.LoginProvider);
|
|
|
|
if (authType == null)
|
|
{
|
|
_logger.LogWarning("Could not find external authentication provider registered: {LoginProvider}", unlinkLoginModel.LoginProvider);
|
|
}
|
|
else
|
|
{
|
|
BackOfficeExternaLoginProviderScheme? opt = await _externalAuthenticationOptions.GetAsync(authType.Name);
|
|
if (opt == null)
|
|
{
|
|
return BadRequest(
|
|
$"Could not find external authentication options registered for provider {unlinkLoginModel.LoginProvider}");
|
|
}
|
|
|
|
if (!opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking)
|
|
{
|
|
// If AllowManualLinking is disabled for this provider we cannot unlink
|
|
return BadRequest();
|
|
}
|
|
}
|
|
|
|
IdentityResult result = await _userManager.RemoveLoginAsync(
|
|
user,
|
|
unlinkLoginModel.LoginProvider,
|
|
unlinkLoginModel.ProviderKey);
|
|
|
|
if (result.Succeeded)
|
|
{
|
|
await _signInManager.SignInAsync(user, true);
|
|
return Ok();
|
|
}
|
|
|
|
AddModelErrors(result);
|
|
return new ValidationErrorResult(ModelState);
|
|
}
|
|
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public async Task<double> GetRemainingTimeoutSeconds()
|
|
{
|
|
// force authentication to occur since this is not an authorized endpoint
|
|
AuthenticateResult result = await this.AuthenticateBackOfficeAsync();
|
|
if (!result.Succeeded)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var remainingSeconds = result.Principal.GetRemainingAuthSeconds();
|
|
if (remainingSeconds <= 30)
|
|
{
|
|
var username = result.Principal.FindFirst(ClaimTypes.Name)?.Value;
|
|
|
|
//NOTE: We are using 30 seconds because that is what is coded into angular to force logout to give some headway in
|
|
// the timeout process.
|
|
|
|
_logger.LogInformation(
|
|
"User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}",
|
|
username ?? "unknown",
|
|
_ipResolver.GetCurrentRequestIpAddress());
|
|
}
|
|
|
|
return remainingSeconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest)
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public async Task<bool> IsAuthenticated()
|
|
{
|
|
// force authentication to occur since this is not an authorized endpoint
|
|
AuthenticateResult result = await this.AuthenticateBackOfficeAsync();
|
|
return result.Succeeded;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the currently logged in Umbraco user
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
/// <remarks>
|
|
/// We have the attribute [SetAngularAntiForgeryTokens] applied because this method is called initially to determine if
|
|
/// the user
|
|
/// is valid before the login screen is displayed. The Auth cookie can be persisted for up to a day but the csrf
|
|
/// cookies are only session
|
|
/// cookies which means that the auth cookie could be valid but the csrf cookies are no longer there, in that case we
|
|
/// need to re-set the csrf cookies.
|
|
/// </remarks>
|
|
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
|
|
[SetAngularAntiForgeryTokens]
|
|
[CheckIfUserTicketDataIsStale]
|
|
public UserDetail? GetCurrentUser()
|
|
{
|
|
IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
|
|
UserDetail? result = _umbracoMapper.Map<UserDetail>(user);
|
|
|
|
if (result is not null)
|
|
{
|
|
//set their remaining seconds
|
|
result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// When a user is invited they are not approved but we need to resolve the partially logged on (non approved)
|
|
/// user.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
/// <remarks>
|
|
/// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't
|
|
/// require them to be approved
|
|
/// </remarks>
|
|
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)]
|
|
[SetAngularAntiForgeryTokens]
|
|
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
|
|
public ActionResult<UserDetail?> GetCurrentInvitedUser()
|
|
{
|
|
IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
|
|
|
|
if (user?.IsApproved ?? false)
|
|
{
|
|
// if they are approved, than they are no longer invited and we can return an error
|
|
return Forbid();
|
|
}
|
|
|
|
UserDetail? result = _umbracoMapper.Map<UserDetail>(user);
|
|
|
|
if (result is not null)
|
|
{
|
|
// set their remaining seconds
|
|
result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs a user in
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[SetAngularAntiForgeryTokens]
|
|
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
|
|
public async Task<ActionResult<UserDetail?>> PostLogin(LoginModel loginModel)
|
|
{
|
|
// Sign the user in with username/password, this also gives a chance for developers to
|
|
// custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
|
|
SignInResult result = await _signInManager.PasswordSignInAsync(
|
|
loginModel.Username, loginModel.Password, true, true);
|
|
|
|
if (result.Succeeded)
|
|
{
|
|
// return the user detail
|
|
return GetUserDetail(_userService.GetByUsername(loginModel.Username));
|
|
}
|
|
|
|
if (result.RequiresTwoFactor)
|
|
{
|
|
var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
|
|
if (twofactorView.IsNullOrWhiteSpace())
|
|
{
|
|
return new ValidationErrorResult(
|
|
$"The registered {typeof(IBackOfficeTwoFactorOptions)} of type {_backOfficeTwoFactorOptions.GetType()} did not return a view for two factor auth ");
|
|
}
|
|
|
|
IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
|
|
|
|
// create a with information to display a custom two factor send code view
|
|
var verifyResponse =
|
|
new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
|
|
{
|
|
StatusCode = StatusCodes.Status402PaymentRequired
|
|
};
|
|
|
|
return verifyResponse;
|
|
}
|
|
|
|
// TODO: We can check for these and respond differently if we think it's important
|
|
// result.IsLockedOut
|
|
// result.IsNotAllowed
|
|
|
|
// return BadRequest (400), we don't want to return a 401 because that get's intercepted
|
|
// by our angular helper because it thinks that we need to re-perform the request once we are
|
|
// authorized and we don't want to return a 403 because angular will show a warning message indicating
|
|
// that the user doesn't have access to perform this function, we just want to return a normal invalid message.
|
|
return BadRequest();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a password reset request. Looks for a match on the provided email address
|
|
/// and if found sends an email with a link to reset it
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[SetAngularAntiForgeryTokens]
|
|
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
|
|
public async Task<IActionResult> PostRequestPasswordReset(RequestPasswordResetModel model)
|
|
{
|
|
// If this feature is switched off in configuration the UI will be amended to not make the request to reset password available.
|
|
// So this is just a server-side secondary check.
|
|
if (_securitySettings.AllowPasswordReset == false)
|
|
{
|
|
return BadRequest();
|
|
}
|
|
|
|
BackOfficeIdentityUser? identityUser = await _userManager.FindByEmailAsync(model.Email);
|
|
if (identityUser != null)
|
|
{
|
|
IUser? user = _userService.GetByEmail(model.Email);
|
|
if (user != null)
|
|
{
|
|
var from = _globalSettings.Smtp?.From;
|
|
var code = await _userManager.GeneratePasswordResetTokenAsync(identityUser);
|
|
var callbackUrl = ConstructCallbackUrl(identityUser.Id, code);
|
|
|
|
var message = _textService.Localize("login", "resetPasswordEmailCopyFormat",
|
|
// Ensure the culture of the found user is used for the email!
|
|
UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings),
|
|
new[] { identityUser.UserName, callbackUrl });
|
|
|
|
var subject = _textService.Localize("login", "resetPasswordEmailCopySubject",
|
|
// Ensure the culture of the found user is used for the email!
|
|
UmbracoUserExtensions.GetUserCulture(identityUser.Culture, _textService, _globalSettings));
|
|
|
|
var mailMessage = new EmailMessage(from, user.Email, subject, message, true);
|
|
|
|
await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.PasswordReset, true);
|
|
|
|
_userManager.NotifyForgotPasswordRequested(User, user.Id.ToString());
|
|
}
|
|
}
|
|
|
|
await Task.Delay(RandomNumberGenerator.GetInt32(400, 2500));
|
|
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Used to retrieve the 2FA providers for code submission
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[SetAngularAntiForgeryTokens]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult<IEnumerable<string>>> Get2FAProviders()
|
|
{
|
|
BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("Get2FAProviders :: No verified user found, returning 404");
|
|
return NotFound();
|
|
}
|
|
|
|
IEnumerable<string> userFactors = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key);
|
|
|
|
return new ObjectResult(userFactors);
|
|
}
|
|
|
|
[SetAngularAntiForgeryTokens]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> PostSend2FACode([FromBody] string provider)
|
|
{
|
|
if (provider.IsNullOrWhiteSpace())
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("PostSend2FACode :: No verified user found, returning 404");
|
|
return NotFound();
|
|
}
|
|
|
|
var from = _globalSettings.Smtp?.From;
|
|
// Generate the token and send it
|
|
var code = await _userManager.GenerateTwoFactorTokenAsync(user, provider);
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
{
|
|
_logger.LogWarning("PostSend2FACode :: Could not generate 2FA code");
|
|
return BadRequest("Invalid code");
|
|
}
|
|
|
|
var subject = _textService.Localize("login", "mfaSecurityCodeSubject",
|
|
// Ensure the culture of the found user is used for the email!
|
|
UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings));
|
|
|
|
var message = _textService.Localize("login", "mfaSecurityCodeMessage",
|
|
// Ensure the culture of the found user is used for the email!
|
|
UmbracoUserExtensions.GetUserCulture(user.Culture, _textService, _globalSettings),
|
|
new[] { code });
|
|
|
|
if (provider == "Email")
|
|
{
|
|
var mailMessage = new EmailMessage(from, user.Email, subject, message, true);
|
|
await _emailSender.SendAsync(mailMessage, Constants.Web.EmailTypes.TwoFactorAuth);
|
|
}
|
|
else if (provider == "Phone")
|
|
{
|
|
var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
|
|
if (phoneNumber is not null)
|
|
{
|
|
await _smsSender.SendSmsAsync(phoneNumber, message);
|
|
}
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
[SetAngularAntiForgeryTokens]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult<UserDetail?>> PostVerify2FACode(Verify2FACodeModel model)
|
|
{
|
|
if (ModelState.IsValid == false)
|
|
{
|
|
return new ValidationErrorResult(ModelState);
|
|
}
|
|
|
|
BackOfficeIdentityUser? user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("PostVerify2FACode :: No verified user found, returning 404");
|
|
return NotFound();
|
|
}
|
|
|
|
SignInResult result =
|
|
await _signInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient);
|
|
if (result.Succeeded)
|
|
{
|
|
return Ok(GetUserDetail(_userService.GetByUsername(user.UserName)));
|
|
}
|
|
|
|
if (result.IsLockedOut)
|
|
{
|
|
return new ValidationErrorResult("User is locked out");
|
|
}
|
|
|
|
if (result.IsNotAllowed)
|
|
{
|
|
return new ValidationErrorResult("User is not allowed");
|
|
}
|
|
|
|
return new ValidationErrorResult("Invalid code");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a set password request. Validates the request and sets a new password.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[SetAngularAntiForgeryTokens]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> PostSetPassword(SetPasswordModel model)
|
|
{
|
|
BackOfficeIdentityUser? identityUser =
|
|
await _userManager.FindByIdAsync(model.UserId.ToString(CultureInfo.InvariantCulture));
|
|
if (identityUser is null)
|
|
{
|
|
return new ValidationErrorResult("Could not find user");
|
|
}
|
|
|
|
IdentityResult result = await _userManager.ResetPasswordAsync(identityUser, model.ResetCode, model.Password);
|
|
if (result.Succeeded)
|
|
{
|
|
var lockedOut = await _userManager.IsLockedOutAsync(identityUser);
|
|
if (lockedOut)
|
|
{
|
|
_logger.LogInformation(
|
|
"User {UserId} is currently locked out, unlocking and resetting AccessFailedCount", model.UserId);
|
|
|
|
//// var user = await UserManager.FindByIdAsync(model.UserId);
|
|
IdentityResult unlockResult =
|
|
await _userManager.SetLockoutEndDateAsync(identityUser, DateTimeOffset.Now);
|
|
if (unlockResult.Succeeded == false)
|
|
{
|
|
_logger.LogWarning("Could not unlock for user {UserId} - error {UnlockError}", model.UserId,
|
|
unlockResult.Errors.First().Description);
|
|
}
|
|
|
|
IdentityResult resetAccessFailedCountResult =
|
|
await _userManager.ResetAccessFailedCountAsync(identityUser);
|
|
if (resetAccessFailedCountResult.Succeeded == false)
|
|
{
|
|
_logger.LogWarning("Could not reset access failed count {UserId} - error {UnlockError}",
|
|
model.UserId, unlockResult.Errors.First().Description);
|
|
}
|
|
}
|
|
|
|
// They've successfully set their password, we can now update their user account to be confirmed
|
|
// if user was only invited, then they have not been approved
|
|
// but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite)
|
|
// means we have verified their email
|
|
if (!await _userManager.IsEmailConfirmedAsync(identityUser))
|
|
{
|
|
await _userManager.ConfirmEmailAsync(identityUser, model.ResetCode);
|
|
}
|
|
|
|
// invited is not approved, never logged in, invited date present
|
|
/*
|
|
if (LastLoginDate == default && IsApproved == false && InvitedDate != null)
|
|
return UserState.Invited;
|
|
*/
|
|
if (identityUser != null && !identityUser.IsApproved)
|
|
{
|
|
IUser? user = _userService.GetByUsername(identityUser.UserName);
|
|
// also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password
|
|
if (user?.LastLoginDate == default && user?.InvitedDate != null)
|
|
{
|
|
user.IsApproved = true;
|
|
user.InvitedDate = null;
|
|
_userService.Save(user);
|
|
}
|
|
}
|
|
|
|
_userManager.NotifyForgotPasswordChanged(User, model.UserId.ToString(CultureInfo.InvariantCulture));
|
|
return Ok();
|
|
}
|
|
|
|
return new ValidationErrorResult(
|
|
result.Errors.Any() ? result.Errors.First().Description : "Set password failed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs the current user out
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
[ValidateAngularAntiForgeryToken]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> PostLogout()
|
|
{
|
|
// force authentication to occur since this is not an authorized endpoint
|
|
AuthenticateResult result = await this.AuthenticateBackOfficeAsync();
|
|
if (!result.Succeeded)
|
|
{
|
|
return Ok();
|
|
}
|
|
|
|
await _signInManager.SignOutAsync();
|
|
|
|
_logger.LogInformation("User {UserName} from IP address {RemoteIpAddress} has logged out",
|
|
result.Principal.Identity == null ? "UNKNOWN" : result.Principal.Identity.Name, HttpContext.Connection.RemoteIpAddress);
|
|
|
|
var userId = result.Principal.Identity?.GetUserId();
|
|
SignOutSuccessResult args = _userManager.NotifyLogoutSuccess(User, userId);
|
|
if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace())
|
|
{
|
|
return new ObjectResult(new { signOutRedirectUrl = args.SignOutRedirectUrl });
|
|
}
|
|
|
|
return Ok();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Return the <see cref="UserDetail" /> for the given <see cref="IUser" />
|
|
/// </summary>
|
|
/// <param name="user"></param>
|
|
/// <returns></returns>
|
|
private UserDetail? GetUserDetail(IUser? user)
|
|
{
|
|
if (user == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(user));
|
|
}
|
|
|
|
UserDetail? userDetail = _umbracoMapper.Map<UserDetail>(user);
|
|
|
|
if (userDetail is not null)
|
|
{
|
|
// update the userDetail and set their remaining seconds
|
|
userDetail.SecondsUntilTimeout = _globalSettings.TimeOut.TotalSeconds;
|
|
}
|
|
|
|
return userDetail;
|
|
}
|
|
|
|
private string ConstructCallbackUrl(string userId, string code)
|
|
{
|
|
// Get an mvc helper to get the url
|
|
var action = _linkGenerator.GetPathByAction(
|
|
nameof(BackOfficeController.ValidatePasswordResetCode),
|
|
ControllerExtensions.GetControllerName<BackOfficeController>(),
|
|
new { area = Constants.Web.Mvc.BackOfficeArea, u = userId, r = code });
|
|
|
|
// Construct full URL using configured application URL (which will fall back to current request)
|
|
Uri applicationUri = _httpContextAccessor.GetRequiredHttpContext().Request
|
|
.GetApplicationUri(_webRoutingSettings);
|
|
var callbackUri = new Uri(applicationUri, action);
|
|
return callbackUri.ToString();
|
|
}
|
|
|
|
private void AddModelErrors(IdentityResult result, string prefix = "")
|
|
{
|
|
foreach (IdentityError? error in result.Errors)
|
|
{
|
|
ModelState.AddModelError(prefix, error.Description);
|
|
}
|
|
}
|
|
}
|