diff --git a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs index 7c65b43291..6519f8a36b 100644 --- a/src/Umbraco.Core/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeSignInManager.cs @@ -35,7 +35,7 @@ namespace Umbraco.Core.Security public static BackOfficeSignInManager Create(IdentityFactoryOptions options, IOwinContext context, ILogger logger) { return new BackOfficeSignInManager( - context.GetBackOfficeUserManager(), + context.GetBackOfficeUserManager(), context.Authentication, logger, context.Request); @@ -48,8 +48,8 @@ namespace Umbraco.Core.Security /// public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool shouldLockout) { - var result = await base.PasswordSignInAsync(userName, password, isPersistent, shouldLockout); - + var result = await PasswordSignInAsyncImpl(userName, password, isPersistent, shouldLockout); + switch (result) { case SignInStatus.Success: @@ -69,7 +69,7 @@ namespace Umbraco.Core.Security case SignInStatus.RequiresVerification: _logger.WriteCore(TraceEventType.Information, 0, string.Format( - "Login attempt failed for username {0} from IP address {1}, the user requires verification", + "Login attempt requires verification for username {0} from IP address {1}", userName, _request.RemoteIpAddress), null, null); break; @@ -87,6 +87,68 @@ namespace Umbraco.Core.Security return result; } + /// + /// Borrowed from Micorosoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type + /// + /// + /// + /// + /// + /// + private async Task PasswordSignInAsyncImpl(string userName, string password, bool isPersistent, bool shouldLockout) + { + if (UserManager == null) + { + return SignInStatus.Failure; + } + var user = await UserManager.FindByNameAsync(userName); + if (user == null) + { + return SignInStatus.Failure; + } + if (await UserManager.IsLockedOutAsync(user.Id)) + { + return SignInStatus.LockedOut; + } + if (await UserManager.CheckPasswordAsync(user, password)) + { + await UserManager.ResetAccessFailedCountAsync(user.Id); + return await SignInOrTwoFactor(user, isPersistent); + } + if (shouldLockout) + { + // If lockout is requested, increment access failed count which might lock out the user + await UserManager.AccessFailedAsync(user.Id); + if (await UserManager.IsLockedOutAsync(user.Id)) + { + return SignInStatus.LockedOut; + } + } + return SignInStatus.Failure; + } + + /// + /// Borrowed from Micorosoft's underlying sign in manager which is not flexible enough to tell it to use a different cookie type + /// + /// + /// + /// + private async Task SignInOrTwoFactor(BackOfficeIdentityUser user, bool isPersistent) + { + var id = Convert.ToString(user.Id); + if (await UserManager.GetTwoFactorEnabledAsync(user.Id) + && (await UserManager.GetValidTwoFactorProvidersAsync(user.Id)).Count > 0 + && await AuthenticationManager.TwoFactorBrowserRememberedAsync(id) == false) + { + var identity = new ClaimsIdentity(Constants.Security.BackOfficeTwoFactorAuthenticationType); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id)); + AuthenticationManager.SignIn(identity); + return SignInStatus.RequiresVerification; + } + await SignInAsync(user, isPersistent, false); + return SignInStatus.Success; + } + /// /// Creates a user identity and then signs the identity using the AuthenticationManager /// @@ -100,11 +162,11 @@ namespace Umbraco.Core.Security // Clear any partial cookies from external or two factor partial sign ins AuthenticationManager.SignOut( - Constants.Security.BackOfficeExternalAuthenticationType, + Constants.Security.BackOfficeExternalAuthenticationType, Constants.Security.BackOfficeTwoFactorAuthenticationType); var nowUtc = DateTime.Now.ToUniversalTime(); - + if (rememberBrowser) { var rememberBrowserIdentity = AuthenticationManager.CreateTwoFactorRememberBrowserIdentity(ConvertIdToString(user.Id)); @@ -133,5 +195,22 @@ namespace Umbraco.Core.Security user.UserName, _request.RemoteIpAddress), null, null); } + + /// + /// Get the user id that has been verified already or null. + /// + /// + /// + /// Replaces the underlying call which is not flexible and doesn't support a custom cookie + /// + public new async Task GetVerifiedUserIdAsync() + { + var result = await AuthenticationManager.AuthenticateAsync(Constants.Security.BackOfficeTwoFactorAuthenticationType); + if (result != null && result.Identity != null && string.IsNullOrEmpty(result.Identity.GetUserId()) == false) + { + return ConvertIdFromString(result.Identity.GetUserId()); + } + return -1; + } } } \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js index 20ebaa10c0..ad29bd93f3 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/auth.resource.js @@ -13,6 +13,42 @@ function authResource($q, $http, umbRequestHelper, angularHelper) { return { + get2FAProviders: function () { + + return umbRequestHelper.resourcePromise( + $http.get( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "Get2FAProviders")), + 'Could not retrive two factor provider info'); + }, + + send2FACode: function (provider) { + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostSend2FACode"), + { + provider: provider + }), + 'Could not send code'); + }, + + verify2FACode: function (code) { + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostVerify2FACode"), + { + code: code + }), + 'Could not verify code'); + }, + /** * @ngdoc method * @name umbraco.resources.authResource#performLogin diff --git a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js index 86137888fa..262b06dd50 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js @@ -4,6 +4,7 @@ angular.module('umbraco.services') var currentUser = null; var lastUserId = null; var loginDialog = null; + //this tracks the last date/time that the user's remainingAuthSeconds was updated from the server // this is used so that we know when to go and get the user's remaining seconds directly. var lastServerTimeoutSet = null; @@ -25,7 +26,7 @@ angular.module('umbraco.services') } }); } - } + } function onLoginDialogClose(success) { loginDialog = null; @@ -182,8 +183,7 @@ angular.module('umbraco.services') /** Internal method to display the login dialog */ _showLoginDialog: function () { openLoginDialog(); - }, - + }, /** Returns a promise, sends a request to the server to check if the current cookie is authorized */ isAuthenticated: function () { //if we've got a current user then just return true @@ -199,17 +199,18 @@ angular.module('umbraco.services') authenticate: function (login, password) { return authResource.performLogin(login, password) - .then(function (data) { + .then(this.setAuthenticationSuccessful); + }, + setAuthenticationSuccessful:function (data) { - //when it's successful, return the user data - setCurrentUser(data); + //when it's successful, return the user data + setCurrentUser(data); var result = { user: data, authenticated: true, lastUserId: lastUserId, loginType: "credentials" }; - //broadcast a global event - eventsService.emit("app.authenticated", result); - return result; - }); + //broadcast a global event + eventsService.emit("app.authenticated", result); + return result; }, /** Logs the user out diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js index aa037b7431..d5687aa6b7 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.controller.js @@ -1,5 +1,5 @@ angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", - function ($scope, $cookies, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource) { + function ($scope, $cookies, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource, dialogService) { var setFieldFocus = function(form, field) { $timeout(function() { @@ -7,6 +7,23 @@ }); } + var twoFactorloginDialog = null; + function show2FALoginDialog(view, callback) { + if (!twoFactorloginDialog) { + twoFactorloginDialog = dialogService.open({ + + //very special flag which means that global events cannot close this dialog + manualClose: true, + template: view, + modalClass: "login-overlay", + animation: "slide", + show: true, + callback: callback, + + }); + } + } + function resetInputValidation() { $scope.confirmPassword = ""; $scope.password = ""; @@ -94,15 +111,24 @@ } userService.authenticate(login, password) - .then(function (data) { - $scope.submit(true); - }, function (reason) { - $scope.errorMsg = reason.errorMsg; + .then(function(data) { + $scope.submit(true); + }, + function(reason) { - //set the form inputs to invalid - $scope.loginForm.username.$setValidity("auth", false); - $scope.loginForm.password.$setValidity("auth", false); - }); + //is Two Factor required? + if (reason.status === 402) { + $scope.errorMsg = "Additional authentication required"; + show2FALoginDialog(reason.data.twoFactorView, $scope.submit); + } + else { + $scope.errorMsg = reason.errorMsg; + + //set the form inputs to invalid + $scope.loginForm.username.$setValidity("auth", false); + $scope.loginForm.password.$setValidity("auth", false); + } + }); //setup a watch for both of the model values changing, if they change // while the form is invalid, then revalidate them so that the form can diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index 14dbdac1d4..09aa9b3279 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -161,7 +161,7 @@ namespace Umbraco.Web.Editors HttpStatusCode.BadRequest, "UserManager does not implement " + typeof(IUmbracoBackOfficeTwoFactorOptions))); } - + var twofactorView = twofactorOptions.GetTwoFactorView( TryGetOwinContext().Result, UmbracoContext, @@ -175,10 +175,13 @@ namespace Umbraco.Web.Editors typeof(IUmbracoBackOfficeTwoFactorOptions) + ".GetTwoFactorView returned an empty string")); } + var attemptedUser = Security.GetBackOfficeUser(loginModel.Username); + //create a with information to display a custom two factor send code view - var verifyResponse = Request.CreateResponse(HttpStatusCode.OK, new + var verifyResponse = Request.CreateResponse(HttpStatusCode.PaymentRequired, new { - twoFactorView = twofactorView + twoFactorView = twofactorView, + userId = attemptedUser.Id }); return verifyResponse; @@ -233,25 +236,48 @@ namespace Umbraco.Web.Editors return Request.CreateResponse(HttpStatusCode.OK); } - private string ConstructCallbackUrl(int userId, string code) + /// + /// Used to retrived the 2FA providers for code submission + /// + /// + [SetAngularAntiForgeryTokens] + public async Task> Get2FAProviders() { - // Get an mvc helper to get the url - var http = EnsureHttpContext(); - var urlHelper = new UrlHelper(http.Request.RequestContext); - var action = urlHelper.Action("ValidatePasswordResetCode", "BackOffice", - new - { - area = GlobalSettings.UmbracoMvcArea, - u = userId, - r = code - }); + var userId = await SignInManager.GetVerifiedUserIdAsync(); + if (userId < 0) + { + //TODO: Or just return not found? + throw new HttpResponseException( + Request.CreateValidationErrorResponse("No verified user found")); + } + var userFactors = await UserManager.GetValidTwoFactorProvidersAsync(userId); + return userFactors; + } + + [SetAngularAntiForgeryTokens] + public async Task PostSend2FACode([FromBody]string provider) + { + // Generate the token and send it + if (await SignInManager.SendTwoFactorCodeAsync(provider) == false) + { + throw new HttpResponseException( + Request.CreateValidationErrorResponse("Invalid code")); + } + return Ok(); + } + + [SetAngularAntiForgeryTokens] + public async Task PostVerify2FACode([FromBody]string code) + { + // Generate the token and send it + if (await SignInManager.SendTwoFactorCodeAsync(code) == false) + { + throw new HttpResponseException( + Request.CreateValidationErrorResponse("Invalid code")); + } + return Ok(); + } - // Construct full URL using configured application URL (which will fall back to request) - var applicationUri = new Uri(ApplicationContext.UmbracoApplicationUrl); - var callbackUri = new Uri(applicationUri, action); - return callbackUri.ToString(); - } - /// /// Processes a set password request. Validates the request and sets a new password. /// @@ -269,13 +295,6 @@ namespace Umbraco.Web.Editors result.Errors.Any() ? result.Errors.First() : "Set password failed"); } - private HttpContextBase EnsureHttpContext() - { - var attempt = this.TryGetHttpContext(); - if (attempt.Success == false) - throw new InvalidOperationException("This method requires that an HttpContext be active"); - return attempt.Result; - } /// /// Logs the current user out @@ -295,6 +314,35 @@ namespace Umbraco.Web.Editors return Request.CreateResponse(HttpStatusCode.OK); } + private string ConstructCallbackUrl(int userId, string code) + { + // Get an mvc helper to get the url + var http = EnsureHttpContext(); + var urlHelper = new UrlHelper(http.Request.RequestContext); + var action = urlHelper.Action("ValidatePasswordResetCode", "BackOffice", + new + { + area = GlobalSettings.UmbracoMvcArea, + u = userId, + r = code + }); + + // Construct full URL using configured application URL (which will fall back to request) + var applicationUri = new Uri(ApplicationContext.UmbracoApplicationUrl); + var callbackUri = new Uri(applicationUri, action); + return callbackUri.ToString(); + } + + + private HttpContextBase EnsureHttpContext() + { + var attempt = this.TryGetHttpContext(); + if (attempt.Success == false) + throw new InvalidOperationException("This method requires that an HttpContext be active"); + return attempt.Result; + } + + private void AddModelErrors(IdentityResult result, string prefix = "") {