diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 71ed456ce4..93f62130bd 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -28,7 +28,7 @@ public const string StartContentNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startcontentnode"; public const string StartMediaNodeIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/startmedianode"; public const string AllowedApplicationsClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/allowedapps"; - public const string UserIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/userid"; + //public const string UserIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/userid"; public const string CultureClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/culture"; public const string SessionIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/sessionid"; diff --git a/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs b/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs index c94ee1193f..652c9df714 100644 --- a/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs +++ b/src/Umbraco.Core/Models/Rdbms/ExternalLoginDto.cs @@ -6,7 +6,7 @@ namespace Umbraco.Core.Models.Rdbms { [TableName("umbracoExternalLogin")] [ExplicitColumns] - [PrimaryKey("externalLoginId")] + [PrimaryKey("Id")] internal class ExternalLoginDto { [Column("id")] diff --git a/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs index bafe30d901..97c6e4fcf5 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ExternalLoginRepository.cs @@ -35,7 +35,7 @@ namespace Umbraco.Core.Persistence.Repositories using (var t = Database.GetTransaction()) { //clear out logins for member - Database.Execute("DELETE FROM ExternalLogins WHERE UserId=@userId", new { userId = memberId }); + Database.Execute("DELETE FROM umbracoExternalLogin WHERE userId=@userId", new { userId = memberId }); //add them all foreach (var l in logins) diff --git a/src/Umbraco.Core/Security/BackOfficeUserStore.cs b/src/Umbraco.Core/Security/BackOfficeUserStore.cs index 8347bfe55c..83676b9c1f 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserStore.cs @@ -337,7 +337,7 @@ namespace Umbraco.Core.Security { //return the first member that matches the result var output = (from l in result - select _userService.GetUserById(l.Id) + select _userService.GetUserById(l.UserId) into user where user != null select Mapper.Map(user)).FirstOrDefault(); diff --git a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs index 08f53fb76e..bf3bc7016c 100644 --- a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs @@ -98,14 +98,15 @@ namespace Umbraco.Core.Security { AddClaims(new[] { + //This is the id that 'identity' uses to check for the user id + new Claim(ClaimTypes.NameIdentifier, Id.ToString(), null, Issuer, Issuer, this), + new Claim(Constants.Security.StartContentNodeIdClaimType, StartContentNode.ToInvariantString(), null, Issuer, Issuer, this), new Claim(Constants.Security.StartMediaNodeIdClaimType, StartMediaNode.ToInvariantString(), null, Issuer, Issuer, this), new Claim(Constants.Security.AllowedApplicationsClaimType, string.Join(",", AllowedApplications), null, Issuer, Issuer, this), //TODO: Similar one created by the ClaimsIdentityFactory not sure we need this - new Claim(Constants.Security.UserIdClaimType, Id.ToString(), null, Issuer, Issuer, this), - new Claim(Constants.Security.CultureClaimType, Culture, null, Issuer, Issuer, this), - new Claim(Constants.Security.SessionIdClaimType, SessionId, null, Issuer, Issuer, this), + new Claim(Constants.Security.CultureClaimType, Culture, null, Issuer, Issuer, this) //TODO: Role claims are added by the default ClaimsIdentityFactory based on the result from // the user manager manager.GetRolesAsync method so not sure if we can do that there or needs to be done here @@ -113,6 +114,13 @@ namespace Umbraco.Core.Security //new Claim(ClaimTypes.Role, string.Join(",", Roles), null, Issuer, Issuer, this) }); + + //TODO: Find out why sessionid is null - this depends on how the identity is created! + if (SessionId.IsNullOrWhiteSpace() == false) + { + AddClaim(new Claim(Constants.Security.SessionIdClaimType, SessionId, null, Issuer, Issuer, this)); + } + } protected internal UserData UserData { get; private set; } diff --git a/src/Umbraco.Web.UI.Client/src/app.js b/src/Umbraco.Web.UI.Client/src/app.js index 847fc2c57c..c541e0776c 100644 --- a/src/Umbraco.Web.UI.Client/src/app.js +++ b/src/Umbraco.Web.UI.Client/src/app.js @@ -10,4 +10,12 @@ var app = angular.module('umbraco', [ 'blueimp.fileupload', 'tmh.dynamicLocale' ]); -var packages = angular.module("umbraco.packages", []); \ No newline at end of file +var packages = angular.module("umbraco.packages", []); + +//Call a document callback if defined, this is sort of a dodgy hack to +// be able to configure angular values in the Default.cshtml +// view which is much easier to do that configuring values by injecting them in the back office controller +// to follow through to the js initialization stuff +if (angular.isFunction(document.angularReady)) { + document.angularReady.apply(this, [app]); +} \ 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 e83b35e087..973b841cdd 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 @@ -52,6 +52,24 @@ function authResource($q, $http, umbRequestHelper, angularHelper) { 'Login failed for user ' + username); }, + unlinkLogin: function (loginProvider, providerKey) { + if (!loginProvider || !providerKey) { + return angularHelper.rejectedPromise({ + errorMsg: 'loginProvider or providerKey cannot be empty' + }); + } + + return umbRequestHelper.resourcePromise( + $http.post( + umbRequestHelper.getApiUrl( + "authenticationApiBaseUrl", + "PostUnLinkLogin"), { + loginProvider: loginProvider, + providerKey: providerKey + }), + 'Unlinking login provider failed'); + }, + /** * @ngdoc method * @name umbraco.resources.authResource#performLogout 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 04e6672b4b..e73c60685f 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,64 +1,66 @@ -angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", function ($scope, localizationService, userService) { - - /** - * @ngdoc function - * @name signin - * @methodOf MainController - * @function - * - * @description - * signs the user in - */ - var d = new Date(); - //var weekday = new Array("Super Sunday", "Manic Monday", "Tremendous Tuesday", "Wonderful Wednesday", "Thunder Thursday", "Friendly Friday", "Shiny Saturday"); - localizationService.localize("login_greeting"+d.getDay()).then(function(label){ - $scope.greeting = label; - }); // weekday[d.getDay()]; - - $scope.errorMsg = ""; +angular.module("umbraco").controller("Umbraco.Dialogs.LoginController", + function ($scope, localizationService, userService, externalLoginInfo) { - $scope.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; - $scope.externalLoginProviders = Umbraco.Sys.ServerVariables.externalLogins.providers; + /** + * @ngdoc function + * @name signin + * @methodOf MainController + * @function + * + * @description + * signs the user in + */ + var d = new Date(); + //var weekday = new Array("Super Sunday", "Manic Monday", "Tremendous Tuesday", "Wonderful Wednesday", "Thunder Thursday", "Friendly Friday", "Shiny Saturday"); + localizationService.localize("login_greeting" + d.getDay()).then(function (label) { + $scope.greeting = label; + }); // weekday[d.getDay()]; - $scope.loginSubmit = function (login, password) { - - //if the login and password are not empty we need to automatically - // validate them - this is because if there are validation errors on the server - // then the user has to change both username & password to resubmit which isn't ideal, - // so if they're not empty , we'l just make sure to set them to valid. - if (login && password && login.length > 0 && password.length > 0) { - $scope.loginForm.username.$setValidity('auth', true); - $scope.loginForm.password.$setValidity('auth', true); - } - - - if ($scope.loginForm.$invalid) { - return; - } + $scope.errorMsg = ""; - userService.authenticate(login, password) - .then(function (data) { - $scope.submit(true); - }, function (reason) { - $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 - // be submitted again. - $scope.loginForm.username.$viewChangeListeners.push(function () { - if ($scope.loginForm.username.$invalid) { + $scope.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl; + $scope.externalLoginProviders = externalLoginInfo.providers; + $scope.externalLoginInfo = externalLoginInfo; + + $scope.loginSubmit = function (login, password) { + + //if the login and password are not empty we need to automatically + // validate them - this is because if there are validation errors on the server + // then the user has to change both username & password to resubmit which isn't ideal, + // so if they're not empty , we'l just make sure to set them to valid. + if (login && password && login.length > 0 && password.length > 0) { $scope.loginForm.username.$setValidity('auth', true); - } - }); - $scope.loginForm.password.$viewChangeListeners.push(function () { - if ($scope.loginForm.password.$invalid) { $scope.loginForm.password.$setValidity('auth', true); } - }); - }; -}); + + + if ($scope.loginForm.$invalid) { + return; + } + + userService.authenticate(login, password) + .then(function (data) { + $scope.submit(true); + }, function (reason) { + $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 + // be submitted again. + $scope.loginForm.username.$viewChangeListeners.push(function () { + if ($scope.loginForm.username.$invalid) { + $scope.loginForm.username.$setValidity('auth', true); + } + }); + $scope.loginForm.password.$viewChangeListeners.push(function () { + if ($scope.loginForm.password.$invalid) { + $scope.loginForm.password.$setValidity('auth', true); + } + }); + }; + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html index b09c11bf18..4981b4ca12 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/login.html @@ -23,12 +23,18 @@
{{errorMsg}}
- -

+ +
+ +

External login providers

+ +
+ {{error}} +
-
+
- - -
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js index 5979962128..c6531e6016 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.controller.js @@ -1,10 +1,12 @@ angular.module("umbraco") - .controller("Umbraco.Dialogs.UserController", function ($scope, $location, $timeout, userService, historyService, eventsService) { + .controller("Umbraco.Dialogs.UserController", function ($scope, $location, $timeout, userService, historyService, eventsService, externalLoginInfo, authResource) { - $scope.user = userService.getCurrentUser(); $scope.history = historyService.getCurrent(); $scope.version = Umbraco.Sys.ServerVariables.application.version + " assembly: " + Umbraco.Sys.ServerVariables.application.assemblyVersion; + $scope.externalLoginProviders = externalLoginInfo.providers; + $scope.externalLinkLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLinkLoginsUrl; + var evtHandlers = []; evtHandlers.push(eventsService.on("historyService.add", function (e, args) { $scope.history = args.all; @@ -49,6 +51,20 @@ angular.module("umbraco") }, 1000, false); // 1 second, do NOT execute a global digest } + $scope.unlink = function(e, loginProvider, providerKey) { + var result = confirm("Are you sure you want to unlink this account?"); + if (!result) { + e.preventDefault(); + return; + } + + authResource.unlinkLogin(loginProvider, providerKey).then(function (a, b, c) { + var asdf = ";" + }, function(err) { + var asdf = err; + }); + } + //get the user userService.getCurrentUser().then(function (user) { $scope.user = user; @@ -57,6 +73,16 @@ angular.module("umbraco") $scope.canEditProfile = _.indexOf($scope.user.allowedSections, "users") > -1; //set the timer updateTimeout(); + + //set the linked logins + for (var login in $scope.user.linkedLogins) { + var found = _.find($scope.externalLoginProviders, function(i) { + return i.authType == login; + }); + if (found) { + found.linkedProviderKey = $scope.user.linkedLogins[login]; + } + } } }); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html index ff71cec524..c73de2bb3a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/dialogs/user.html @@ -1,48 +1,76 @@
-
-
-
- -
-
-

{{user.name}}

-

- - : {{remainingAuthSeconds | timespan}} -

+
+
+
+ +
+
+

{{user.name}}

+

+ + : {{remainingAuthSeconds | timespan}} + +

-
- -
-
- -
-
-

- - Edit - -

-
+
+
+
-
-
- -
+
+
+

+ + Edit + +

+
-
+
- Umbraco version {{version}} -
+
External login providers
+ +
+ +
+ +
+ + + + {{login.caption}} + +
+ +
+ +
+
+ +
+ +
+ + Umbraco version {{version}} +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI/App_Code/OwinStartup.cs b/src/Umbraco.Web.UI/App_Code/OwinStartup.cs index baf56c1bb5..a11e071aa3 100644 --- a/src/Umbraco.Web.UI/App_Code/OwinStartup.cs +++ b/src/Umbraco.Web.UI/App_Code/OwinStartup.cs @@ -63,7 +63,9 @@ namespace Umbraco.Web.UI //app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); - + app.UseGoogleAuthentication( + clientId: "1072120697051-07jlhgrd5hodsfe7dgqimdie8qc1omet.apps.googleusercontent.com", + clientSecret: "Ue9swN0lEX9rwxzQz1Y_tFzg"); } diff --git a/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml b/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml index 55c28da1f3..c51e1c6ab9 100644 --- a/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml +++ b/src/Umbraco.Web.UI/umbraco/Views/Default.cshtml @@ -1,4 +1,5 @@ -@using System.Net.Http +@using System.Collections +@using System.Net.Http @using System.Web.Mvc.Html @using Umbraco.Core @using ClientDependency.Core @@ -52,9 +53,13 @@ @{ var loginProviders = Context.GetOwinContext().Authentication.GetExternalAuthenticationTypes() - .Select(p => new {authType = p.AuthenticationType, caption = p.Caption, + .Select(p => new + { + authType = p.AuthenticationType, + caption = p.Caption, //TODO: Need to see if this exposes any sensitive data! - properties = p.Properties}) + properties = p.Properties + }) .ToArray(); } @@ -63,23 +68,42 @@ we will load the rest of the server vars after the user is authenticated. *@ + + + + @*And finally we can load in our angular app*@ @@ -104,4 +128,3 @@ - diff --git a/src/Umbraco.Web/Editors/AuthenticationController.cs b/src/Umbraco.Web/Editors/AuthenticationController.cs index e3f13d46f3..f6fcb2c0e7 100644 --- a/src/Umbraco.Web/Editors/AuthenticationController.cs +++ b/src/Umbraco.Web/Editors/AuthenticationController.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; +using System.Security.Claims; using System.Text; +using System.Threading.Tasks; using System.Web; using System.Web.Helpers; using System.Web.Http; @@ -24,6 +27,9 @@ using Umbraco.Web.Security; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; using umbraco.providers; +using Microsoft.AspNet.Identity.Owin; +using Newtonsoft.Json.Linq; +using Umbraco.Core.Models.Identity; namespace Umbraco.Web.Editors { @@ -58,6 +64,54 @@ namespace Umbraco.Web.Editors throw new NotSupportedException("An HttpContext is required for this request"); } + [WebApi.UmbracoAuthorize] + [ValidateAngularAntiForgeryToken] + public async Task PostUnLinkLogin(UnLinkLoginModel unlinkLoginModel) + { + var result = await UserManager.RemoveLoginAsync( + User.Identity.GetUserId(), + new UserLoginInfo(unlinkLoginModel.LoginProvider, unlinkLoginModel.ProviderKey)); + + if (result.Succeeded) + { + var user = await UserManager.FindByIdAsync(User.Identity.GetUserId()); + await SignInAsync(user, isPersistent: false); + return Request.CreateResponse(HttpStatusCode.OK); + } + else + { + AddModelErrors(result); + return Request.CreateValidationErrorResponse(ModelState); + } + } + + private void AddModelErrors(IdentityResult result, string prefix = "") + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(prefix, error); + } + } + + private async Task SignInAsync(BackOfficeIdentityUser user, bool isPersistent) + { + var owinContext = TryGetOwinContext().Result; + + owinContext.Authentication.SignOut(Core.Constants.Security.BackOfficeExternalAuthenticationType); + + owinContext.Authentication.SignIn( + new AuthenticationProperties() { IsPersistent = isPersistent }, + await GenerateUserIdentityAsync(user)); + } + + private async Task GenerateUserIdentityAsync(BackOfficeIdentityUser user) + { + // NOTE the authenticationType must match the umbraco one + // defined in CookieAuthenticationOptions.AuthenticationType + var userIdentity = await UserManager.CreateIdentityAsync(user, global::Umbraco.Core.Constants.Security.BackOfficeAuthenticationType); + return userIdentity; + } + /// /// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest) /// @@ -85,7 +139,7 @@ namespace Umbraco.Web.Editors /// [WebApi.UmbracoAuthorize] [SetAngularAntiForgeryTokens] - public UserDetail GetCurrentUser() + public async Task GetCurrentUser() { var user = Services.UserService.GetUserById(UmbracoContext.Security.GetUserId()); var result = Mapper.Map(user); @@ -95,9 +149,23 @@ namespace Umbraco.Web.Editors //set their remaining seconds result.SecondsUntilTimeout = httpContextAttempt.Result.GetRemainingAuthSeconds(); } + + //now we need to fill in the user's linked logins, we can't do this in the mapper because it has no access to the + // user manager + + var identityUser = await UserManager.FindByIdAsync(user.Id); + result.LinkedLogins = identityUser.Logins.ToDictionary(x => x.LoginProvider, x => x.ProviderKey); + return result; } + private BackOfficeUserManager _userManager; + + protected BackOfficeUserManager UserManager + { + get { return _userManager ?? (_userManager = TryGetOwinContext().Result.GetUserManager()); } + } + /// /// Logs a user in /// diff --git a/src/Umbraco.Web/Editors/BackOfficeController.cs b/src/Umbraco.Web/Editors/BackOfficeController.cs index 2ee30bc632..47866de5c9 100644 --- a/src/Umbraco.Web/Editors/BackOfficeController.cs +++ b/src/Umbraco.Web/Editors/BackOfficeController.cs @@ -52,11 +52,6 @@ namespace Umbraco.Web.Editors { private BackOfficeUserManager _userManager; - protected IOwinContext OwinContext - { - get { return Request.GetOwinContext(); } - } - protected BackOfficeUserManager UserManager { get { return _userManager ?? (_userManager = OwinContext.GetUserManager()); } @@ -70,30 +65,25 @@ namespace Umbraco.Web.Editors { ViewBag.UmbracoPath = GlobalSettings.UmbracoMvcArea; + //check if there's errors in the TempData, assign to view bag and render the view + if (TempData["ExternalSignInError"] != null) + { + ViewBag.ExternalSignInError = TempData["ExternalSignInError"]; + return View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml"); + } + //First check if there's external login info, if there's not proceed as normal var loginInfo = await OwinContext.Authentication.GetExternalLoginInfoAsync( Core.Constants.Security.BackOfficeExternalAuthenticationType); - + if (loginInfo == null) { return View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml"); } - // Sign in the user with this external login provider if the user already has a login - var user = await UserManager.FindAsync(loginInfo.Login); - if (user != null) - { - await SignInAsync(user, isPersistent: false); - //all signed in so just render the view as per normal - return View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml"); - } + //we're just logging in with an external source, not linking accounts + return await ExternalSignInAsync(loginInfo); - //The user hasn't used this login provider so need to display an error, they must link the provider in the user section - // TODO: Or wherever we decide to put that. - - // TODO: Return a real error in one way or another, maybe a different view? - - throw new SecurityNegotiationException("The requested provider " + loginInfo.Login.LoginProvider + " has not been linked to to an account"); } /// @@ -244,6 +234,8 @@ namespace Umbraco.Web.Editors { "umbracoUrls", new Dictionary { + {"externalLoginsUrl", Url.Action("ExternalLogin", "BackOffice")}, + {"externalLinkLoginsUrl", Url.Action("LinkLogin", "BackOffice")}, {"legacyTreeJs", Url.Action("LegacyTreeJs", "BackOffice")}, {"manifestAssetList", Url.Action("GetManifestAssetList", "BackOffice")}, {"gridConfig", Url.Action("GetGridConfig", "BackOffice")}, @@ -326,10 +318,10 @@ namespace Umbraco.Web.Editors controller => controller.Fetch(string.Empty)) }, { - "relationApiBaseUrl", Url.GetUmbracoApiServiceBaseUrl( - controller => controller.GetById(0)) - }, - { + "relationApiBaseUrl", Url.GetUmbracoApiServiceBaseUrl( + controller => controller.GetById(0)) + }, + { "rteApiBaseUrl", Url.GetUmbracoApiServiceBaseUrl( controller => controller.GetConfiguration()) }, @@ -395,7 +387,24 @@ namespace Umbraco.Web.Editors } }, {"isDebuggingEnabled", HttpContext.IsDebuggingEnabled}, - {"application", GetApplicationState()} + { + "application", GetApplicationState() + }, + { + "externalLogins", new Dictionary + { + { + "providers", HttpContext.GetOwinContext().Authentication.GetExternalAuthenticationTypes() + .Select(p => new + { + authType = p.AuthenticationType, caption = p.Caption, + //TODO: Need to see if this exposes any sensitive data! + properties = p.Properties + }) + .ToArray() + } + } + } }; //cache the result if debugging is disabled @@ -410,22 +419,78 @@ namespace Umbraco.Web.Editors } [HttpPost] - [AllowAnonymous] public ActionResult ExternalLogin(string provider) { // Request a redirect to the external login provider return new ChallengeResult(provider, - Url.Action("Default", "BackOffice", new + Url.Action("Default", "BackOffice")); + } + + [UmbracoAuthorize] + [HttpPost] + public ActionResult LinkLogin(string provider) + { + // Request a redirect to the external login provider to link a login for the current user + return new ChallengeResult(provider, + Url.Action("ExternalLinkLoginCallback", "BackOffice"), + User.Identity.GetUserId()); + } + + + + [HttpGet] + public async Task ExternalLinkLoginCallback() + { + var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync( + Core.Constants.Security.BackOfficeExternalAuthenticationType, + XsrfKey, User.Identity.GetUserId()); + + if (loginInfo == null) + { + //Add error and redirect for it to be displayed + TempData["ExternalSignInError"] = new[] { "An error occurred, could not get external login info" }; + return RedirectToLocal(Url.Action("Default", "BackOffice")); + } + + var result = await UserManager.AddLoginAsync(User.Identity.GetUserId(), loginInfo.Login); + if (result.Succeeded) + { + return RedirectToLocal(Url.Action("Default", "BackOffice")); + } + + //Add errors and redirect for it to be displayed + TempData["ExternalSignInError"] = result.Errors; + return RedirectToLocal(Url.Action("Default", "BackOffice")); + } + + private async Task ExternalSignInAsync(ExternalLoginInfo loginInfo) + { + if (loginInfo == null) throw new ArgumentNullException("loginInfo"); + + // Sign in the user with this external login provider if the user already has a login + var user = await UserManager.FindAsync(loginInfo.Login); + if (user != null) + { + //sign in + await SignInAsync(user, isPersistent: false); + } + else + { + ViewBag.ExternalSignInError = new[] { "The requested provider (" + loginInfo.Login.LoginProvider + ") has not been linked to to an account" }; + + //Remove the cookie otherwise this message will keep appearing + if (Response.Cookies[Core.Constants.Security.BackOfficeExternalAuthenticationType] != null) { - area = GlobalSettings.UmbracoMvcArea - })); + Response.Cookies[Core.Constants.Security.BackOfficeExternalAuthenticationType].Expires = DateTime.MinValue; + } + } + + return View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml"); } private async Task SignInAsync(BackOfficeIdentityUser user, bool isPersistent) { - //TODO: I don't think we want to reference the 'default' external cookie since people might be using this on the front-end - // we'll need to create a secondary custom handler for the external cookie for the back office - OwinContext.Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie); + OwinContext.Authentication.SignOut(Core.Constants.Security.BackOfficeExternalAuthenticationType); OwinContext.Authentication.SignIn( new AuthenticationProperties() {IsPersistent = isPersistent}, @@ -439,6 +504,11 @@ namespace Umbraco.Web.Editors var userIdentity = await UserManager.CreateIdentityAsync(user, global::Umbraco.Core.Constants.Security.BackOfficeAuthenticationType); return userIdentity; } + + private IAuthenticationManager AuthenticationManager + { + get { return OwinContext.Authentication; } + } /// diff --git a/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs b/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs index f32a379218..0d9d859b3d 100644 --- a/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs +++ b/src/Umbraco.Web/Models/ContentEditing/UserDetail.cs @@ -42,5 +42,8 @@ namespace Umbraco.Web.Models.ContentEditing /// [DataMember(Name = "allowedSections")] public IEnumerable AllowedSections { get; set; } + + [DataMember(Name = "linkedLogins")] + public IEnumerable> LinkedLogins { get; set; } } } \ No newline at end of file diff --git a/src/Umbraco.Web/Models/UnLinkLoginModel.cs b/src/Umbraco.Web/Models/UnLinkLoginModel.cs new file mode 100644 index 0000000000..776baf03fc --- /dev/null +++ b/src/Umbraco.Web/Models/UnLinkLoginModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace Umbraco.Web.Models +{ + public class UnLinkLoginModel + { + [Required] + [DataMember(Name = "loginProvider", IsRequired = true)] + public string LoginProvider { get; set; } + + [Required] + [DataMember(Name = "providerKey", IsRequired = true)] + public string ProviderKey { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Web/Mvc/UmbracoController.cs b/src/Umbraco.Web/Mvc/UmbracoController.cs index 1ab13cedac..241d22b16e 100644 --- a/src/Umbraco.Web/Mvc/UmbracoController.cs +++ b/src/Umbraco.Web/Mvc/UmbracoController.cs @@ -1,5 +1,7 @@ using System; +using System.Web; using System.Web.Mvc; +using Microsoft.Owin; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Services; @@ -24,6 +26,11 @@ namespace Umbraco.Web.Mvc } + protected IOwinContext OwinContext + { + get { return Request.GetOwinContext(); } + } + private UmbracoHelper _umbraco; /// diff --git a/src/Umbraco.Web/Security/Identity/AuthenticationManagerExtensions.cs b/src/Umbraco.Web/Security/Identity/AuthenticationManagerExtensions.cs index 52f50418e9..0acd002d42 100644 --- a/src/Umbraco.Web/Security/Identity/AuthenticationManagerExtensions.cs +++ b/src/Umbraco.Web/Security/Identity/AuthenticationManagerExtensions.cs @@ -36,6 +36,38 @@ namespace Umbraco.Web.Security.Identity }; } + /// + /// Extracts login info out of an external identity + /// + /// + /// + /// key that will be used to find the userId to verify + /// + /// the value expected to be found using the xsrfKey in the AuthenticationResult.Properties + /// dictionary + /// + /// + public static async Task GetExternalLoginInfoAsync(this IAuthenticationManager manager, + string authenticationType, + string xsrfKey, string expectedValue) + { + if (manager == null) + { + throw new ArgumentNullException("manager"); + } + var result = await manager.AuthenticateAsync(authenticationType); + // Verify that the userId is the same as what we expect if requested + if (result != null && + result.Properties != null && + result.Properties.Dictionary != null && + result.Properties.Dictionary.ContainsKey(xsrfKey) && + result.Properties.Dictionary[xsrfKey] == expectedValue) + { + return GetExternalLoginInfo(result); + } + return null; + } + /// /// Extracts login info out of an external identity /// diff --git a/src/Umbraco.Web/UI/JavaScript/Main.js b/src/Umbraco.Web/UI/JavaScript/Main.js index 520620af7d..afc4706ca3 100644 --- a/src/Umbraco.Web/UI/JavaScript/Main.js +++ b/src/Umbraco.Web/UI/JavaScript/Main.js @@ -3,6 +3,8 @@ LazyLoad.js("##JsInitialize##", function () { UmbClientMgr.setUmbracoPath('"##UmbracoPath##"'); jQuery(document).ready(function () { + angular.bootstrap(document, ['umbraco']); + }); }); \ No newline at end of file diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index f40dc7bbd1..1edac1acb3 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -316,6 +316,7 @@ + diff --git a/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs b/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs index 8eeada541e..bd2290ce17 100644 --- a/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs +++ b/src/Umbraco.Web/WebApi/HttpRequestMessageExtensions.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using System.Web; using System.Web.Http; using System.Web.Http.ModelBinding; +using Microsoft.Owin; using Umbraco.Core; namespace Umbraco.Web.WebApi @@ -16,6 +17,20 @@ namespace Umbraco.Web.WebApi public static class HttpRequestMessageExtensions { + + /// + /// Borrowed from the latest Microsoft.AspNet.WebApi.Owin package which we cannot use because of a later webapi dependency + /// + /// + /// + internal static Attempt TryGetOwinContext(this HttpRequestMessage request) + { + var httpContext = request.TryGetHttpContext(); + return httpContext + ? Attempt.Succeed(httpContext.Result.GetOwinContext()) + : Attempt.Fail(); + } + /// /// Tries to retrieve the current HttpContext if one exists. /// diff --git a/src/Umbraco.Web/WebApi/UmbracoApiController.cs b/src/Umbraco.Web/WebApi/UmbracoApiController.cs index 9b1a37f4f7..33df591015 100644 --- a/src/Umbraco.Web/WebApi/UmbracoApiController.cs +++ b/src/Umbraco.Web/WebApi/UmbracoApiController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Web; using System.Web.Http; using System.Web.Http.ModelBinding; +using Microsoft.Owin; using Umbraco.Core; using Umbraco.Core.Logging; using Umbraco.Core.Models; @@ -42,6 +43,15 @@ namespace Umbraco.Web.WebApi return Request.TryGetHttpContext(); } + /// + /// Tries to retrieve the current HttpContext if one exists. + /// + /// + protected Attempt TryGetOwinContext() + { + return Request.TryGetOwinContext(); + } + /// /// Returns an ILogger ///