using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; 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.Membership; using Umbraco.Cms.Core.Models.Security; using Umbraco.Cms.Core.Net; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.BackOffice.Extensions; 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.Extensions; using Constants = Umbraco.Cms.Core.Constants; 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] 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 IBackOfficeUserManager _userManager; private readonly IBackOfficeSignInManager _signInManager; private readonly IUserService _userService; private readonly ILocalizedTextService _textService; private readonly UmbracoMapper _umbracoMapper; private readonly GlobalSettings _globalSettings; private readonly SecuritySettings _securitySettings; private readonly ILogger _logger; private readonly IIpResolver _ipResolver; private readonly UserPasswordConfigurationSettings _passwordConfiguration; private readonly IEmailSender _emailSender; private readonly ISmsSender _smsSender; private readonly IHostingEnvironment _hostingEnvironment; private readonly LinkGenerator _linkGenerator; private readonly IBackOfficeExternalLoginProviders _externalAuthenticationOptions; private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions; // TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here public AuthenticationController( IBackOfficeSecurityAccessor backofficeSecurityAccessor, IBackOfficeUserManager backOfficeUserManager, IBackOfficeSignInManager signInManager, IUserService userService, ILocalizedTextService textService, UmbracoMapper umbracoMapper, IOptions globalSettings, IOptions securitySettings, ILogger logger, IIpResolver ipResolver, IOptions passwordConfiguration, IEmailSender emailSender, ISmsSender smsSender, IHostingEnvironment hostingEnvironment, LinkGenerator linkGenerator, IBackOfficeExternalLoginProviders externalAuthenticationOptions, IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions) { _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; } /// /// Returns the configuration for the backoffice user membership provider - used to configure the change password dialog /// [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 GetPasswordConfig(int userId) { Attempt currentUserId = _backofficeSecurityAccessor.BackOfficeSecurity.GetUserId(); return _passwordConfiguration.GetConfiguration( currentUserId.Success ? currentUserId.Result != userId : true); } /// /// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object /// /// /// /// /// /// This will also update the security stamp for the user so it can only be used once /// [ValidateAngularAntiForgeryToken] [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task> PostVerifyInvite([FromQuery] int id, [FromQuery] string token) { if (string.IsNullOrWhiteSpace(token)) return NotFound(); var decoded = token.FromUrlBase64(); if (decoded.IsNullOrWhiteSpace()) return NotFound(); var identityUser = await _userManager.FindByIdAsync(id.ToString()); if (identityUser == null) return NotFound(); var result = await _userManager.ConfirmEmailAsync(identityUser, decoded); if (result.Succeeded == false) { return ValidationErrorResult.CreateNotificationValidationErrorResult(result.Errors.ToErrorMessage()); } await _signInManager.SignOutAsync(); await _signInManager.SignInAsync(identityUser, false); var user = _userService.GetUserById(id); return _umbracoMapper.Map(user); } [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] [ValidateAngularAntiForgeryToken] public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) { var user = await _userManager.FindByIdAsync(User.Identity.GetUserId()); if (user == null) throw new InvalidOperationException("Could not find user"); var 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 { var opt = _externalAuthenticationOptions.Get(authType.Name); if (opt == null) { return BadRequest($"Could not find external authentication options registered for provider {unlinkLoginModel.LoginProvider}"); } else { if (!opt.Options.AutoLinkOptions.AllowManualLinking) { // If AllowManualLinking is disabled for this provider we cannot unlink return BadRequest(); } } } var result = await _userManager.RemoveLoginAsync( user, unlinkLoginModel.LoginProvider, unlinkLoginModel.ProviderKey); if (result.Succeeded) { await _signInManager.SignInAsync(user, true); return Ok(); } else { AddModelErrors(result); return new ValidationErrorResult(new SimpleValidationModel(ModelState.ToErrorDictionary())); } } [HttpGet] [AllowAnonymous] public async Task GetRemainingTimeoutSeconds() { // force authentication to occur since this is not an authorized endpoint var 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; } /// /// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest) /// /// [HttpGet] [AllowAnonymous] public async Task IsAuthenticated() { // force authentication to occur since this is not an authorized endpoint var result = await this.AuthenticateBackOfficeAsync(); return result.Succeeded; } /// /// Returns the currently logged in Umbraco user /// /// /// /// 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. /// [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] [SetAngularAntiForgeryTokens] [CheckIfUserTicketDataIsStale] public UserDetail GetCurrentUser() { var user = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser; var result = _umbracoMapper.Map(user); //set their remaining seconds result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); return result; } /// /// When a user is invited they are not approved but we need to resolve the partially logged on (non approved) /// user. /// /// /// /// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't require them to be approved /// [Authorize(Policy = AuthorizationPolicies.BackOfficeAccessWithoutApproval)] [SetAngularAntiForgeryTokens] [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public ActionResult GetCurrentInvitedUser() { var user = _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser; if (user.IsApproved) { // if they are approved, than they are no longer invited and we can return an error return Forbid(); } var result = _umbracoMapper.Map(user); // set their remaining seconds result.SecondsUntilTimeout = HttpContext.User.GetRemainingAuthSeconds(); return result; } /// /// Logs a user in /// /// [SetAngularAntiForgeryTokens] [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task> 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 var result = await _signInManager.PasswordSignInAsync( loginModel.Username, loginModel.Password, isPersistent: true, lockoutOnFailure: 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 "); } var 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; } // 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(); } /// /// 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 /// /// [SetAngularAntiForgeryTokens] [Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)] public async Task 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(); var identityUser = await _userManager.FindByEmailAsync(model.Email); if (identityUser != null) { var 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); _userManager.RaiseForgotPasswordRequestedEvent(User, user.Id.ToString()); } } return Ok(); } /// /// Used to retrieve the 2FA providers for code submission /// /// [SetAngularAntiForgeryTokens] [AllowAnonymous] public async Task>> Get2FAProviders() { var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); if (user == null) { _logger.LogWarning("Get2FAProviders :: No verified user found, returning 404"); return NotFound(); } var userFactors = await _userManager.GetValidTwoFactorProvidersAsync(user); return new ObjectResult(userFactors); } [SetAngularAntiForgeryTokens] [AllowAnonymous] public async Task PostSend2FACode([FromBody] string provider) { if (provider.IsNullOrWhiteSpace()) return NotFound(); var 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); } else if (provider == "Phone") { await _smsSender.SendSmsAsync(await _userManager.GetPhoneNumberAsync(user), message); } return Ok(); } [SetAngularAntiForgeryTokens] [AllowAnonymous] public async Task> PostVerify2FACode(Verify2FACodeModel model) { if (ModelState.IsValid == false) { return new ValidationErrorResult(new SimpleValidationModel(ModelState.ToErrorDictionary())); } var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); if (user == null) { _logger.LogWarning("PostVerify2FACode :: No verified user found, returning 404"); return NotFound(); } var result = await _signInManager.TwoFactorSignInAsync(model.Provider, model.Code, model.IsPersistent, model.RememberClient); if (result.Succeeded) { return 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"); } /// /// Processes a set password request. Validates the request and sets a new password. /// /// [SetAngularAntiForgeryTokens] [AllowAnonymous] public async Task PostSetPassword(SetPasswordModel model) { var identityUser = await _userManager.FindByIdAsync(model.UserId.ToString()); var 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); var 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); } var 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) { var 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.RaiseForgotPasswordChangedSuccessEvent(User, model.UserId.ToString()); return Ok(); } return new ValidationErrorResult(result.Errors.Any() ? result.Errors.First().Description : "Set password failed"); } /// /// Logs the current user out /// /// [ValidateAngularAntiForgeryToken] [AllowAnonymous] public async Task PostLogout() { // force authentication to occur since this is not an authorized endpoint var result = await this.AuthenticateBackOfficeAsync(); if (!result.Succeeded) return Ok(); await _signInManager.SignOutAsync(); _logger.LogInformation("User {UserName} from IP address {RemoteIpAddress} has logged out", User.Identity == null ? "UNKNOWN" : User.Identity.Name, HttpContext.Connection.RemoteIpAddress); var userId = result.Principal.Identity.GetUserId(); var args = _userManager.RaiseLogoutSuccessEvent(User, userId); if (!args.SignOutRedirectUrl.IsNullOrWhiteSpace()) { return new ObjectResult(new { signOutRedirectUrl = args.SignOutRedirectUrl }); } return Ok(); } /// /// Return the for the given /// /// /// /// private UserDetail GetUserDetail(IUser user) { if (user == null) throw new ArgumentNullException(nameof(user)); var userDetail = _umbracoMapper.Map(user); // update the userDetail and set their remaining seconds userDetail.SecondsUntilTimeout = TimeSpan.FromMinutes(_globalSettings.TimeOutInMinutes).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(), new { area = Constants.Web.Mvc.BackOfficeArea, u = userId, r = code }); // Construct full URL using configured application URL (which will fall back to request) var applicationUri = _hostingEnvironment.ApplicationMainUrl; var callbackUri = new Uri(applicationUri, action); return callbackUri.ToString(); } private void AddModelErrors(IdentityResult result, string prefix = "") { foreach (var error in result.Errors) { ModelState.AddModelError(prefix, error.Description); } } } }