From 262c4afb162e0da71a3bb0e7e818d24906e3f5d0 Mon Sep 17 00:00:00 2001 From: Shannon Date: Thu, 5 Apr 2018 23:10:51 +1000 Subject: [PATCH] Removes FormsAuthentication cookie format and replaces with standard aspnet identity format, removes a bunch of old obsolete and unused code, fixes the culture setting issue, simplifies the UmbracoBackOfficeIdentity since it no longer needs to be a FormsIdentity and just a straight forward ClaimsIdentity --- .../Security/AuthenticationExtensions.cs | 523 +++--------------- .../BackOfficeClaimsIdentityFactory.cs | 27 +- .../BackOfficeCookieAuthenticationProvider.cs | 6 +- .../Security/BackOfficeUserManager.cs | 44 +- src/Umbraco.Core/Security/OwinExtensions.cs | 3 + .../Security/UmbracoBackOfficeIdentity.cs | 368 +++++------- src/Umbraco.Core/Security/UserData.cs | 74 --- src/Umbraco.Core/Umbraco.Core.csproj | 1 - .../UmbracoBackOfficeIdentityTests.cs | 91 +-- .../AuthenticateEverythingMiddleware.cs | 11 +- .../Editors/CurrentUserController.cs | 1 + .../Security/AuthenticationExtensions.cs | 378 +++++++++++++ .../Security/Identity/AppBuilderExtensions.cs | 24 +- .../Identity/BackOfficeCookieManager.cs | 2 +- .../FormsAuthenticationSecureDataFormat.cs | 90 --- .../Identity/GetUserSecondsMiddleWare.cs | 8 +- .../UmbracoBackOfficeCookieAuthOptions.cs | 37 +- .../Identity/UmbracoSecureDataFormat.cs | 83 +++ .../Security/LegacyDefaultAppMapping.cs | 49 -- src/Umbraco.Web/Security/OwinExtensions.cs | 12 +- .../UmbracoAuthTicketDataProtector.cs | 19 + .../UI/Pages/UmbracoEnsuredPage.cs | 1 + src/Umbraco.Web/Umbraco.Web.csproj | 5 +- .../UmbracoUserTimeoutFilterAttribute.cs | 11 +- .../controls/Tree/CustomTreeService.cs | 1 + .../umbraco/controls/Tree/TreeControl.ascx.cs | 1 + 26 files changed, 766 insertions(+), 1104 deletions(-) delete mode 100644 src/Umbraco.Core/Security/UserData.cs create mode 100644 src/Umbraco.Web/Security/AuthenticationExtensions.cs delete mode 100644 src/Umbraco.Web/Security/Identity/FormsAuthenticationSecureDataFormat.cs create mode 100644 src/Umbraco.Web/Security/Identity/UmbracoSecureDataFormat.cs delete mode 100644 src/Umbraco.Web/Security/LegacyDefaultAppMapping.cs create mode 100644 src/Umbraco.Web/Security/UmbracoAuthTicketDataProtector.cs diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 5adf163bee..3b497bff86 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -1,466 +1,67 @@ -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Security.Principal; -using System.Threading; -using System.Web; -using System.Web.Security; -using Microsoft.AspNet.Identity; -using AutoMapper; -using Microsoft.Owin; -using Newtonsoft.Json; -using Umbraco.Core.Configuration; -using Umbraco.Core.Composing; -using Umbraco.Core.Models.Membership; -using Umbraco.Core.Logging; -using IUser = Umbraco.Core.Models.Membership.IUser; - -namespace Umbraco.Core.Security -{ - /// - /// Extensions to create and renew and remove authentication tickets for the Umbraco back office - /// - public static class AuthenticationExtensions - { - /// - /// This will check the ticket to see if it is valid, if it is it will set the current thread's user and culture - /// - /// - /// - /// If true will attempt to renew the ticket - public static bool AuthenticateCurrentRequest(this HttpContextBase http, FormsAuthenticationTicket ticket, bool renewTicket) - { - if (http == null) throw new ArgumentNullException("http"); - - //if there was a ticket, it's not expired, - it should not be renewed or its renewable - if (ticket != null && ticket.Expired == false && (renewTicket == false || http.RenewUmbracoAuthTicket())) - { - try - { - //create the Umbraco user identity - var identity = new UmbracoBackOfficeIdentity(ticket); - - //set the principal object - var principal = new GenericPrincipal(identity, identity.Roles); - - //It is actually not good enough to set this on the current app Context and the thread, it also needs - // to be set explicitly on the HttpContext.Current !! This is a strange web api thing that is actually - // an underlying fault of asp.net not propogating the User correctly. - if (HttpContext.Current != null) - { - HttpContext.Current.User = principal; - } - http.User = principal; - Thread.CurrentPrincipal = principal; - - //This is a back office request, we will also set the culture/ui culture - Thread.CurrentThread.CurrentCulture = - Thread.CurrentThread.CurrentUICulture = - new System.Globalization.CultureInfo(identity.Culture); - - return true; - } - catch (Exception ex) - { - if (ex is FormatException || ex is JsonReaderException) - { - //this will occur if the cookie data is invalid - http.UmbracoLogout(); - } - else - { - throw; - } - - } - } - - return false; - } - - /// - /// This will return the current back office identity if the IPrincipal is the correct type - /// - /// - /// - internal static UmbracoBackOfficeIdentity GetUmbracoIdentity(this IPrincipal user) - { - //If it's already a UmbracoBackOfficeIdentity - var backOfficeIdentity = user.Identity as UmbracoBackOfficeIdentity; - if (backOfficeIdentity != null) return backOfficeIdentity; - - //Check if there's more than one identity assigned and see if it's a UmbracoBackOfficeIdentity and use that - var claimsPrincipal = user as ClaimsPrincipal; - if (claimsPrincipal != null) - { - backOfficeIdentity = claimsPrincipal.Identities.OfType().FirstOrDefault(); - if (backOfficeIdentity != null) return backOfficeIdentity; - } - - //Otherwise convert to a UmbracoBackOfficeIdentity if it's auth'd and has the back office session - var claimsIdentity = user.Identity as ClaimsIdentity; - if (claimsIdentity != null && claimsIdentity.IsAuthenticated && claimsIdentity.HasClaim(x => x.Type == Constants.Security.SessionIdClaimType)) - { - try - { - return UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); - } - catch (InvalidOperationException) - { - } - } - - return null; - } - - /// - /// This will return the current back office identity. - /// - /// - /// - /// If set to true and a back office identity is not found and not authenticated, this will attempt to authenticate the - /// request just as is done in the Umbraco module and then set the current identity if it is valid. - /// Just like in the UmbracoModule, if this is true then the user's culture will be assigned to the request. - /// - /// - /// Returns the current back office identity if an admin is authenticated otherwise null - /// - public static UmbracoBackOfficeIdentity GetCurrentIdentity(this HttpContextBase http, bool authenticateRequestIfNotFound) - { - if (http == null) throw new ArgumentNullException("http"); - if (http.User == null) return null; //there's no user at all so no identity - - //If it's already a UmbracoBackOfficeIdentity - var backOfficeIdentity = GetUmbracoIdentity(http.User); - if (backOfficeIdentity != null) return backOfficeIdentity; - - if (authenticateRequestIfNotFound == false) return null; - - //even if authenticateRequestIfNotFound is true we cannot continue if the request is actually authenticated - // which would mean something strange is going on that it is not an umbraco identity. - if (http.User.Identity.IsAuthenticated) return null; - - //So the user is not authed but we've been asked to do the auth if authenticateRequestIfNotFound = true, - // which might occur in old webforms style things or for routes that aren't included as a back office request. - // in this case, we are just reverting to authing using the cookie. - - // TODO: Even though this is in theory legacy, we have legacy bits laying around and we'd need to do the auth based on - // how the Module will eventually do it (by calling in to any registered authenticators). - - var ticket = http.GetUmbracoAuthTicket(); - if (http.AuthenticateCurrentRequest(ticket, true)) - { - //now we 'should have an umbraco identity - return http.User.Identity as UmbracoBackOfficeIdentity; - } - return null; - } - - /// - /// This will return the current back office identity. - /// - /// - /// - /// If set to true and a back office identity is not found and not authenticated, this will attempt to authenticate the - /// request just as is done in the Umbraco module and then set the current identity if it is valid - /// - /// - /// Returns the current back office identity if an admin is authenticated otherwise null - /// - internal static UmbracoBackOfficeIdentity GetCurrentIdentity(this HttpContext http, bool authenticateRequestIfNotFound) - { - if (http == null) throw new ArgumentNullException("http"); - return new HttpContextWrapper(http).GetCurrentIdentity(authenticateRequestIfNotFound); - } - - public static void UmbracoLogout(this HttpContextBase http) - { - if (http == null) throw new ArgumentNullException("http"); - Logout(http, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); - } - - /// - /// This clears the forms authentication cookie - /// - /// - internal static void UmbracoLogout(this HttpContext http) - { - if (http == null) throw new ArgumentNullException("http"); - new HttpContextWrapper(http).UmbracoLogout(); - } - - /// - /// This will force ticket renewal in the OWIN pipeline - /// - /// - /// - public static bool RenewUmbracoAuthTicket(this HttpContextBase http) - { - if (http == null) throw new ArgumentNullException("http"); - http.Items[Constants.Security.ForceReAuthFlag] = true; - return true; - } - - /// - /// This will force ticket renewal in the OWIN pipeline - /// - /// - /// - internal static bool RenewUmbracoAuthTicket(this HttpContext http) - { - if (http == null) throw new ArgumentNullException("http"); - http.Items[Constants.Security.ForceReAuthFlag] = true; - return true; - } - - /// - /// Creates the umbraco authentication ticket - /// - /// - /// - public static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContextBase http, UserData userdata) - { - //ONLY used by BasePage.doLogin! - - if (http == null) throw new ArgumentNullException("http"); - if (userdata == null) throw new ArgumentNullException("userdata"); - - var userDataString = JsonConvert.SerializeObject(userdata); - return CreateAuthTicketAndCookie( - http, - userdata.Username, - userDataString, - //use the configuration timeout - this is the same timeout that will be used when renewing the ticket. - GlobalSettings.TimeOutInMinutes, - //Umbraco has always persisted it's original cookie for 1 day so we'll keep it that way - 1440, - UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, - UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain); - } - - /// - /// returns the number of seconds the user has until their auth session times out - /// - /// - /// - public static double GetRemainingAuthSeconds(this HttpContextBase http) - { - if (http == null) throw new ArgumentNullException("http"); - var ticket = http.GetUmbracoAuthTicket(); - return ticket.GetRemainingAuthSeconds(); - } - - /// - /// returns the number of seconds the user has until their auth session times out - /// - /// - /// - public static double GetRemainingAuthSeconds(this FormsAuthenticationTicket ticket) - { - if (ticket == null) - { - return 0; - } - var utcExpired = ticket.Expiration.ToUniversalTime(); - var secondsRemaining = utcExpired.Subtract(DateTime.UtcNow).TotalSeconds; - return secondsRemaining; - } - - /// - /// Gets the umbraco auth ticket - /// - /// - /// - public static FormsAuthenticationTicket GetUmbracoAuthTicket(this HttpContextBase http) - { - if (http == null) throw new ArgumentNullException("http"); - return GetAuthTicket(http, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); - } - - internal static FormsAuthenticationTicket GetUmbracoAuthTicket(this HttpContext http) - { - if (http == null) throw new ArgumentNullException("http"); - return new HttpContextWrapper(http).GetUmbracoAuthTicket(); - } - - internal static FormsAuthenticationTicket GetUmbracoAuthTicket(this IOwinContext ctx) - { - if (ctx == null) throw new ArgumentNullException("ctx"); - //get the ticket - try - { - return GetAuthTicket(ctx.Request.Cookies.ToDictionary(x => x.Key, x => x.Value), UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); - } - catch (Exception) - { - ctx.Authentication.SignOut( - Constants.Security.BackOfficeAuthenticationType, - Constants.Security.BackOfficeExternalAuthenticationType); - return null; - } - } - - /// - /// This clears the forms authentication cookie - /// - /// - /// - private static void Logout(this HttpContextBase http, string cookieName) +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Umbraco.Core.Security +{ + public static class AuthenticationExtensions + { + /// + /// This will return the current back office identity if the IPrincipal is the correct type + /// + /// + /// + internal static UmbracoBackOfficeIdentity GetUmbracoIdentity(this IPrincipal user) { - //We need to clear the sessionId from the database. This is legacy code to do any logging out and shouldn't really be used at all but in any case - //we need to make sure the session is cleared. Due to the legacy nature of this it means we need to use singletons - if (http.User != null) - { - var claimsIdentity = http.User.Identity as ClaimsIdentity; - if (claimsIdentity != null) - { - var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); - Guid guidSession; - if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) - { - Current.Services.UserService.ClearLoginSession(guidSession); - } + //If it's already a UmbracoBackOfficeIdentity + if (user.Identity is UmbracoBackOfficeIdentity backOfficeIdentity) return backOfficeIdentity; + + //Check if there's more than one identity assigned and see if it's a UmbracoBackOfficeIdentity and use that + if (user is ClaimsPrincipal claimsPrincipal) + { + backOfficeIdentity = claimsPrincipal.Identities.OfType().FirstOrDefault(); + if (backOfficeIdentity != null) return backOfficeIdentity; + } + + //Otherwise convert to a UmbracoBackOfficeIdentity if it's auth'd and has the back office session + if (user.Identity is ClaimsIdentity claimsIdentity && claimsIdentity.IsAuthenticated && claimsIdentity.HasClaim(x => x.Type == Constants.Security.SessionIdClaimType)) + { + try + { + return UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); + } + catch (InvalidOperationException) + { } } - - if (http == null) throw new ArgumentNullException("http"); - //clear the preview cookie and external login - var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName }; - foreach (var c in cookies) - { - //remove from the request - http.Request.Cookies.Remove(c); - - //expire from the response - var formsCookie = http.Response.Cookies[c]; - if (formsCookie != null) - { - //this will expire immediately and be removed from the browser - formsCookie.Expires = DateTime.Now.AddYears(-1); - } - else - { - //ensure there's def an expired cookie - http.Response.Cookies.Add(new HttpCookie(c) { Expires = DateTime.Now.AddYears(-1) }); - } - } - } - - private static FormsAuthenticationTicket GetAuthTicket(this HttpContextBase http, string cookieName) - { - var asDictionary = new Dictionary(); - for (var i = 0; i < http.Request.Cookies.Keys.Count; i++) - { - var key = http.Request.Cookies.Keys.Get(i); - asDictionary[key] = http.Request.Cookies[key].Value; - } - - //get the ticket - try - { - - return GetAuthTicket(asDictionary, cookieName); - } - catch (Exception) - { - //occurs when decryption fails - http.Logout(cookieName); - return null; - } - } - - private static FormsAuthenticationTicket GetAuthTicket(IDictionary cookies, string cookieName) - { - if (cookies == null) throw new ArgumentNullException("cookies"); - - if (cookies.ContainsKey(cookieName) == false) return null; - - var formsCookie = cookies[cookieName]; - if (formsCookie == null) - { - return null; - } - //get the ticket - return FormsAuthentication.Decrypt(formsCookie); - } - - /// - /// Creates a custom FormsAuthentication ticket with the data specified - /// - /// The HTTP. - /// The username. - /// The user data. - /// The login timeout mins. - /// The minutes persisted. - /// Name of the cookie. - /// The cookie domain. - private static FormsAuthenticationTicket CreateAuthTicketAndCookie(this HttpContextBase http, - string username, - string userData, - int loginTimeoutMins, - int minutesPersisted, - string cookieName, - string cookieDomain) - { - if (http == null) throw new ArgumentNullException("http"); - // Create a new ticket used for authentication - var ticket = new FormsAuthenticationTicket( - 4, - username, - DateTime.Now, - DateTime.Now.AddMinutes(loginTimeoutMins), - true, - userData, - "/" - ); - - // Encrypt the cookie using the machine key for secure transport - var hash = FormsAuthentication.Encrypt(ticket); - var cookie = new HttpCookie( - cookieName, - hash) - { - Expires = DateTime.Now.AddMinutes(minutesPersisted), - Domain = cookieDomain, - Path = "/" - }; - - if (GlobalSettings.UseSSL) - cookie.Secure = true; - - //ensure http only, this should only be able to be accessed via the server - cookie.HttpOnly = true; - - http.Response.Cookies.Set(cookie); - - return ticket; - } - /// - /// Ensures that the thread culture is set based on the back office user's culture - /// - /// - internal static void EnsureCulture(this IIdentity identity) - { - if (identity is UmbracoBackOfficeIdentity umbIdentity && umbIdentity.IsAuthenticated) - { - Thread.CurrentThread.CurrentUICulture = - Thread.CurrentThread.CurrentCulture = - UserCultures.GetOrAdd(umbIdentity.Culture, s => new CultureInfo(s)); - } + return null; } - - /// - /// Used so that we aren't creating a new CultureInfo object for every single request - /// - private static readonly ConcurrentDictionary UserCultures = new ConcurrentDictionary(); - } -} + + /// + /// Ensures that the thread culture is set based on the back office user's culture + /// + /// + internal static void EnsureCulture(this IIdentity identity) + { + if (identity is UmbracoBackOfficeIdentity umbIdentity && umbIdentity.IsAuthenticated) + { + Thread.CurrentThread.CurrentUICulture = + Thread.CurrentThread.CurrentCulture = UserCultures.GetOrAdd(umbIdentity.Culture, s => new CultureInfo(s)); + } + } + + + /// + /// Used so that we aren't creating a new CultureInfo object for every single request + /// + private static readonly ConcurrentDictionary UserCultures = new ConcurrentDictionary(); + } +} diff --git a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs index cec1ee6bcb..490e667c17 100644 --- a/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs +++ b/src/Umbraco.Core/Security/BackOfficeClaimsIdentityFactory.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNet.Identity; @@ -25,22 +26,20 @@ namespace Umbraco.Core.Security var baseIdentity = await base.CreateAsync(manager, user, authenticationType); var umbracoIdentity = new UmbracoBackOfficeIdentity(baseIdentity, + user.Id, + user.UserName, + user.Name, + user.CalculatedContentStartNodeIds, + user.CalculatedMediaStartNodeIds, + user.Culture, //NOTE - there is no session id assigned here, this is just creating the identity, a session id will be generated when the cookie is written - new UserData - { - Id = user.Id, - Username = user.UserName, - RealName = user.Name, - AllowedApplications = user.AllowedSections, - Culture = user.Culture, - Roles = user.Roles.Select(x => x.RoleId).ToArray(), - StartContentNodes = user.CalculatedContentStartNodeIds, - StartMediaNodes = user.CalculatedMediaStartNodeIds, - SecurityStamp = user.SecurityStamp - }); + Guid.NewGuid().ToString(), + user.SecurityStamp, + user.AllowedSections, + user.Roles.Select(x => x.RoleId).ToArray()); return umbracoIdentity; - } + } } public class BackOfficeClaimsIdentityFactory : BackOfficeClaimsIdentityFactory diff --git a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs index b75dd76e47..a52f720b53 100644 --- a/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs +++ b/src/Umbraco.Core/Security/BackOfficeCookieAuthenticationProvider.cs @@ -28,11 +28,11 @@ namespace Umbraco.Core.Security //create a session token - if we are configured and not in an upgrade state then use the db, otherwise just generate one var session = RuntimeState.Level == RuntimeLevel.Run - ? UserService.CreateLoginSession((int)backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) + ? UserService.CreateLoginSession(backOfficeIdentity.Id, context.OwinContext.GetCurrentRequestIpAddress()) : Guid.NewGuid(); - backOfficeIdentity.UserData.SessionId = session.ToString(); - } + backOfficeIdentity.SessionId = session.ToString(); + } base.ResponseSignIn(context); } diff --git a/src/Umbraco.Core/Security/BackOfficeUserManager.cs b/src/Umbraco.Core/Security/BackOfficeUserManager.cs index 32f7d3bd8f..4c651e9432 100644 --- a/src/Umbraco.Core/Security/BackOfficeUserManager.cs +++ b/src/Umbraco.Core/Security/BackOfficeUserManager.cs @@ -31,16 +31,6 @@ namespace Umbraco.Core.Security { } - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the constructor specifying all dependencies instead")] - public BackOfficeUserManager( - IUserStore store, - IdentityFactoryOptions options, - MembershipProviderBase membershipProvider) - : this(store, options, membershipProvider, UmbracoConfig.For.UmbracoSettings().Content) - { - } - public BackOfficeUserManager( IUserStore store, IdentityFactoryOptions options, @@ -84,17 +74,6 @@ namespace Umbraco.Core.Security return manager; } - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the overload specifying all dependencies instead")] - public static BackOfficeUserManager Create( - IdentityFactoryOptions options, - BackOfficeUserStore customUserStore, - MembershipProviderBase membershipProvider) - { - var manager = new BackOfficeUserManager(customUserStore, options, membershipProvider); - return manager; - } - /// /// Creates a BackOfficeUserManager instance with all default options and a custom BackOfficeUserManager instance /// @@ -114,16 +93,6 @@ namespace Umbraco.Core.Security } #endregion - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the overload specifying all dependencies instead")] - protected void InitUserManager( - BackOfficeUserManager manager, - MembershipProviderBase membershipProvider, - IdentityFactoryOptions options) - { - InitUserManager(manager, membershipProvider, UmbracoConfig.For.UmbracoSettings().Content, options); - } - /// /// Initializes the user manager with the correct options /// @@ -154,7 +123,6 @@ namespace Umbraco.Core.Security { } - #region What we support do not currently //TODO: We could support this - but a user claims will mostly just be what is in the auth cookie @@ -183,17 +151,7 @@ namespace Umbraco.Core.Security get { return false; } } #endregion - - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Use the overload specifying all dependencies instead")] - protected void InitUserManager( - BackOfficeUserManager manager, - MembershipProviderBase membershipProvider, - IDataProtectionProvider dataProtectionProvider) - { - InitUserManager(manager, membershipProvider, dataProtectionProvider, UmbracoConfig.For.UmbracoSettings().Content); - } - + /// /// Initializes the user manager with the correct options /// diff --git a/src/Umbraco.Core/Security/OwinExtensions.cs b/src/Umbraco.Core/Security/OwinExtensions.cs index b3bb5bffda..a4d596855c 100644 --- a/src/Umbraco.Core/Security/OwinExtensions.cs +++ b/src/Umbraco.Core/Security/OwinExtensions.cs @@ -2,6 +2,7 @@ using System.Web; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; +using Microsoft.Owin.Security; using Umbraco.Core.Models.Identity; namespace Umbraco.Core.Security @@ -67,5 +68,7 @@ namespace Umbraco.Core.Security return marker.GetManager(owinContext) ?? throw new NullReferenceException($"Could not resolve an instance of {typeof (BackOfficeUserManager)} from the {typeof (IOwinContext)}."); } + } + } diff --git a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs index 605c8b4e9d..6cf5eacc97 100644 --- a/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs +++ b/src/Umbraco.Core/Security/UmbracoBackOfficeIdentity.cs @@ -7,6 +7,7 @@ using System.Security.Principal; using System.Web; using System.Web.Security; using Microsoft.AspNet.Identity; +using Microsoft.Owin.Security; using Newtonsoft.Json; using Umbraco.Core.Configuration; @@ -21,10 +22,81 @@ namespace Umbraco.Core.Security /// change over to 'pure' asp.net identity and just inherit from ClaimsIdentity. /// [Serializable] - public class UmbracoBackOfficeIdentity : FormsIdentity + public class UmbracoBackOfficeIdentity : ClaimsIdentity { public static UmbracoBackOfficeIdentity FromClaimsIdentity(ClaimsIdentity identity) { + return new UmbracoBackOfficeIdentity(identity); + } + + /// + /// Creates a new UmbracoBackOfficeIdentity + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public UmbracoBackOfficeIdentity(int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) + : base(Enumerable.Empty(), Constants.Security.BackOfficeAuthenticationType) //this ctor is used to ensure the IsAuthenticated property is true + { + if (allowedApps == null) throw new ArgumentNullException(nameof(allowedApps)); + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); + if (string.IsNullOrWhiteSpace(realName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(realName)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(culture)); + if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(securityStamp)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(securityStamp)); + AddRequiredClaims(userId, username, realName, startContentNodes, startMediaNodes, culture, sessionId, securityStamp, allowedApps, roles); + } + + /// + /// Creates a new UmbracoBackOfficeIdentity + /// + /// + /// The original identity created by the ClaimsIdentityFactory + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public UmbracoBackOfficeIdentity(ClaimsIdentity childIdentity, + int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) + : base(childIdentity.Claims, Constants.Security.BackOfficeAuthenticationType) + { + if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(username)); + if (string.IsNullOrWhiteSpace(realName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(realName)); + if (string.IsNullOrWhiteSpace(culture)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(culture)); + if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(securityStamp)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(securityStamp)); + Actor = childIdentity; + AddRequiredClaims(userId, username, realName, startContentNodes, startMediaNodes, culture, sessionId, securityStamp, allowedApps, roles); + } + + /// + /// Create a back office identity based on an existing claims identity + /// + /// + private UmbracoBackOfficeIdentity(ClaimsIdentity identity) + : base(identity.Claims, Constants.Security.BackOfficeAuthenticationType) + { + Actor = identity; + + //validate that all claims exist foreach (var t in RequiredBackOfficeIdentityClaimTypes) { //if the identity doesn't have the claim, or the claim value is null @@ -33,145 +105,9 @@ namespace Umbraco.Core.Security throw new InvalidOperationException("Cannot create a " + typeof(UmbracoBackOfficeIdentity) + " from " + typeof(ClaimsIdentity) + " since the required claim " + t + " is missing"); } } - - var username = identity.GetUserName(); - var session = identity.FindFirstValue(Constants.Security.SessionIdClaimType); - var securityStamp = identity.FindFirstValue(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType); - var startContentId = identity.FindFirstValue(Constants.Security.StartContentNodeIdClaimType); - var startMediaId = identity.FindFirstValue(Constants.Security.StartMediaNodeIdClaimType); - - var culture = identity.FindFirstValue(ClaimTypes.Locality); - var id = identity.FindFirstValue(ClaimTypes.NameIdentifier); - var realName = identity.FindFirstValue(ClaimTypes.GivenName); - - if (username == null || startContentId == null || startMediaId == null - || culture == null || id == null - || realName == null || session == null) - throw new InvalidOperationException("Cannot create a " + typeof(UmbracoBackOfficeIdentity) + " from " + typeof(ClaimsIdentity) + " since there are missing required claims"); - - int[] startContentIdsAsInt; - int[] startMediaIdsAsInt; - if (startContentId.DetectIsJson() == false || startMediaId.DetectIsJson() == false) - throw new InvalidOperationException("Cannot create a " + typeof(UmbracoBackOfficeIdentity) + " from " + typeof(ClaimsIdentity) + " since the data is not formatted correctly - either content or media start Ids are not JSON"); - - try - { - startContentIdsAsInt = JsonConvert.DeserializeObject(startContentId); - startMediaIdsAsInt = JsonConvert.DeserializeObject(startMediaId); - } - catch (Exception e) - { - throw new InvalidOperationException("Cannot create a " + typeof(UmbracoBackOfficeIdentity) + " from " + typeof(ClaimsIdentity) + " since the data is not formatted correctly - either content or media start Ids could not be parsed as JSON", e); - } - - var roles = identity.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToList(); - var allowedApps = identity.FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToList(); - - var userData = new UserData - { - SecurityStamp = securityStamp, - SessionId = session, - AllowedApplications = allowedApps.ToArray(), - Culture = culture, - Id = id, - Roles = roles.ToArray(), - Username = username, - RealName = realName, - StartContentNodes = startContentIdsAsInt, - StartMediaNodes = startMediaIdsAsInt - }; - - return new UmbracoBackOfficeIdentity(identity, userData); } - /// - /// Create a back office identity based on user data - /// - /// - public UmbracoBackOfficeIdentity(UserData userdata) - //This just creates a temp/fake ticket - : base(new FormsAuthenticationTicket(userdata.Username, true, 10)) - { - if (userdata == null) throw new ArgumentNullException("userdata"); - UserData = userdata; - AddUserDataClaims(); - } - - /// - /// Create a back office identity based on an existing claims identity - /// - /// - /// - public UmbracoBackOfficeIdentity(ClaimsIdentity claimsIdentity, UserData userdata) - //This just creates a temp/fake ticket - : base(new FormsAuthenticationTicket(userdata.Username, true, 10)) - { - if (claimsIdentity == null) throw new ArgumentNullException("claimsIdentity"); - if (userdata == null) throw new ArgumentNullException("userdata"); - - if (claimsIdentity is FormsIdentity) - { - //since it's a forms auth ticket, it is from a cookie so add that claim - AddClaim(new Claim(ClaimTypes.CookiePath, "/", ClaimValueTypes.String, Issuer, Issuer, this)); - } - - _currentIssuer = claimsIdentity.AuthenticationType; - UserData = userdata; - AddExistingClaims(claimsIdentity); - Actor = claimsIdentity; - AddUserDataClaims(); - } - - /// - /// Create a new identity from a forms auth ticket - /// - /// - public UmbracoBackOfficeIdentity(FormsAuthenticationTicket ticket) - : base(ticket) - { - //since it's a forms auth ticket, it is from a cookie so add that claim - AddClaim(new Claim(ClaimTypes.CookiePath, "/", ClaimValueTypes.String, Issuer, Issuer, this)); - - UserData = JsonConvert.DeserializeObject(ticket.UserData); - AddUserDataClaims(); - } - - /// - /// Used for cloning - /// - /// - private UmbracoBackOfficeIdentity(UmbracoBackOfficeIdentity identity) - : base(identity) - { - if (identity.Actor != null) - { - _currentIssuer = identity.AuthenticationType; - AddExistingClaims(identity); - Actor = identity.Clone(); - } - - UserData = identity.UserData; - AddUserDataClaims(); - } - - public const string Issuer = "UmbracoBackOffice"; - private readonly string _currentIssuer = Issuer; - - /// - /// Used during ctor to add existing claims from an existing ClaimsIdentity - /// - /// - private void AddExistingClaims(ClaimsIdentity claimsIdentity) - { - foreach (var claim in claimsIdentity.Claims) - { - //In one special case we will replace a claim if it exists already and that is the - // Forms auth claim for name which automatically gets added - TryRemoveClaim(FindFirst(x => x.Type == claim.Type && x.Issuer == "Forms")); - - AddClaim(claim); - } - } + public const string Issuer = Constants.Security.BackOfficeAuthenticationType; /// /// Returns the required claim types for a back office identity @@ -179,153 +115,125 @@ namespace Umbraco.Core.Security /// /// This does not incude the role claim type or allowed apps type since that is a collection and in theory could be empty /// - public static IEnumerable RequiredBackOfficeIdentityClaimTypes + public static IEnumerable RequiredBackOfficeIdentityClaimTypes => new[] { - get - { - return new[] - { - ClaimTypes.NameIdentifier, //id - ClaimTypes.Name, //username - ClaimTypes.GivenName, - Constants.Security.StartContentNodeIdClaimType, - Constants.Security.StartMediaNodeIdClaimType, - ClaimTypes.Locality, - Constants.Security.SessionIdClaimType, - Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType - }; - } - } + ClaimTypes.NameIdentifier, //id + ClaimTypes.Name, //username + ClaimTypes.GivenName, + Constants.Security.StartContentNodeIdClaimType, + Constants.Security.StartMediaNodeIdClaimType, + ClaimTypes.Locality, + Constants.Security.SessionIdClaimType, + Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType + }; /// - /// Adds claims based on the UserData data + /// Adds claims based on the ctor data /// - private void AddUserDataClaims() + private void AddRequiredClaims(int userId, string username, string realName, + IEnumerable startContentNodes, IEnumerable startMediaNodes, string culture, + string sessionId, string securityStamp, IEnumerable allowedApps, IEnumerable roles) { //This is the id that 'identity' uses to check for the user id if (HasClaim(x => x.Type == ClaimTypes.NameIdentifier) == false) - AddClaim(new Claim(ClaimTypes.NameIdentifier, UserData.Id.ToString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); if (HasClaim(x => x.Type == ClaimTypes.Name) == false) - AddClaim(new Claim(ClaimTypes.Name, UserData.Username, ClaimValueTypes.String, Issuer, Issuer, this)); + AddClaim(new Claim(ClaimTypes.Name, username, ClaimValueTypes.String, Issuer, Issuer, this)); if (HasClaim(x => x.Type == ClaimTypes.GivenName) == false) - AddClaim(new Claim(ClaimTypes.GivenName, UserData.RealName, ClaimValueTypes.String, Issuer, Issuer, this)); + AddClaim(new Claim(ClaimTypes.GivenName, realName, ClaimValueTypes.String, Issuer, Issuer, this)); - if (HasClaim(x => x.Type == Constants.Security.StartContentNodeIdClaimType) == false) - AddClaim(new Claim(Constants.Security.StartContentNodeIdClaimType, JsonConvert.SerializeObject(StartContentNodes), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + if (HasClaim(x => x.Type == Constants.Security.StartContentNodeIdClaimType) == false && startContentNodes != null) + { + foreach (var startContentNode in startContentNodes) + { + AddClaim(new Claim(Constants.Security.StartContentNodeIdClaimType, startContentNode.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + } + } - if (HasClaim(x => x.Type == Constants.Security.StartMediaNodeIdClaimType) == false) - AddClaim(new Claim(Constants.Security.StartMediaNodeIdClaimType, JsonConvert.SerializeObject(StartMediaNodes), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + if (HasClaim(x => x.Type == Constants.Security.StartMediaNodeIdClaimType) == false && startMediaNodes != null) + { + foreach (var startMediaNode in startMediaNodes) + { + AddClaim(new Claim(Constants.Security.StartMediaNodeIdClaimType, startMediaNode.ToInvariantString(), ClaimValueTypes.Integer32, Issuer, Issuer, this)); + } + } if (HasClaim(x => x.Type == ClaimTypes.Locality) == false) - AddClaim(new Claim(ClaimTypes.Locality, Culture, ClaimValueTypes.String, Issuer, Issuer, this)); + AddClaim(new Claim(ClaimTypes.Locality, culture, ClaimValueTypes.String, Issuer, Issuer, this)); if (HasClaim(x => x.Type == Constants.Security.SessionIdClaimType) == false && SessionId.IsNullOrWhiteSpace() == false) - AddClaim(new Claim(Constants.Security.SessionIdClaimType, SessionId, ClaimValueTypes.String, Issuer, Issuer, this)); + AddClaim(new Claim(Constants.Security.SessionIdClaimType, sessionId, ClaimValueTypes.String, Issuer, Issuer, this)); //The security stamp claim is also required... this is because this claim type is hard coded // by the SecurityStampValidator, see: https://katanaproject.codeplex.com/workitem/444 if (HasClaim(x => x.Type == Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType) == false) - AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, SecurityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); + AddClaim(new Claim(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType, securityStamp, ClaimValueTypes.String, Issuer, Issuer, this)); //Add each app as a separate claim - if (HasClaim(x => x.Type == Constants.Security.AllowedApplicationsClaimType) == false) + if (HasClaim(x => x.Type == Constants.Security.AllowedApplicationsClaimType) == false && allowedApps != null) { - foreach (var application in AllowedApplications) + foreach (var application in allowedApps) { AddClaim(new Claim(Constants.Security.AllowedApplicationsClaimType, application, ClaimValueTypes.String, Issuer, Issuer, this)); } } //Claims are added by the ClaimsIdentityFactory because our UserStore supports roles, however this identity might - // not be made with that factory if it was created with a FormsAuthentication ticket so perform the check - if (HasClaim(x => x.Type == DefaultRoleClaimType) == false) + // not be made with that factory if it was created with a different ticket so perform the check + if (HasClaim(x => x.Type == DefaultRoleClaimType) == false && roles != null) { - //manually add them based on the UserData - foreach (var roleName in UserData.Roles) + //manually add them + foreach (var roleName in roles) { AddClaim(new Claim(RoleClaimType, roleName, ClaimValueTypes.String, Issuer, Issuer, this)); } } - - } - - protected internal UserData UserData { get; private set; } - + + /// /// /// Gets the type of authenticated identity. /// /// /// The type of authenticated identity. This property always returns "UmbracoBackOffice". /// - public override string AuthenticationType - { - get { return _currentIssuer; } - } + public override string AuthenticationType => Issuer; - public int[] StartContentNodes - { - get { return UserData.StartContentNodes; } - } + private int[] _startContentNodes; + public int[] StartContentNodes => _startContentNodes ?? (_startContentNodes = FindAll(x => x.Type == Constants.Security.StartContentNodeIdClaimType).Select(app => int.TryParse(app.Value, out var i) ? i : default).Where(x => x != default).ToArray()); - public int[] StartMediaNodes - { - get { return UserData.StartMediaNodes; } - } + private int[] _startMediaNodes; + public int[] StartMediaNodes => _startMediaNodes ?? (_startMediaNodes = FindAll(x => x.Type == Constants.Security.StartMediaNodeIdClaimType).Select(app => int.TryParse(app.Value, out var i) ? i : default).Where(x => x != default).ToArray()); - public string[] AllowedApplications - { - get { return UserData.AllowedApplications; } - } + private string[] _allowedApplications; + public string[] AllowedApplications => _allowedApplications ?? (_allowedApplications = FindAll(x => x.Type == Constants.Security.AllowedApplicationsClaimType).Select(app => app.Value).ToArray()); - public object Id - { - get { return UserData.Id; } - } + public int Id => int.Parse(this.FindFirstValue(ClaimTypes.NameIdentifier)); - public string RealName - { - get { return UserData.RealName; } - } + public string RealName => this.FindFirstValue(ClaimTypes.GivenName); - public string Username - { - get { return UserData.Username; } - } + public string Username => this.GetUserName(); - public string Culture - { - get { return UserData.Culture; } - } + public string Culture => this.FindFirstValue(ClaimTypes.Locality); public string SessionId { - get { return UserData.SessionId; } + get => this.FindFirstValue(Constants.Security.SessionIdClaimType); + set + { + var existing = FindFirst(Constants.Security.SessionIdClaimType); + if (existing != null) + TryRemoveClaim(existing); + AddClaim(new Claim(Constants.Security.SessionIdClaimType, value, ClaimValueTypes.String, Issuer, Issuer, this)); + } } - public string SecurityStamp - { - get { return UserData.SecurityStamp; } - } - - public string[] Roles - { - get { return UserData.Roles; } - } - - /// - /// Gets a copy of the current instance. - /// - /// - /// A copy of the current instance. - /// - public override ClaimsIdentity Clone() - { - return new UmbracoBackOfficeIdentity(this); - } + public string SecurityStamp => this.FindFirstValue(Microsoft.AspNet.Identity.Constants.DefaultSecurityStampClaimType); + public string[] Roles => this.FindAll(x => x.Type == DefaultRoleClaimType).Select(role => role.Value).ToArray(); + } } diff --git a/src/Umbraco.Core/Security/UserData.cs b/src/Umbraco.Core/Security/UserData.cs deleted file mode 100644 index 7e510ba708..0000000000 --- a/src/Umbraco.Core/Security/UserData.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace Umbraco.Core.Security -{ - /// - /// Data structure used to store information in the authentication cookie - /// - [DataContract(Name = "userData", Namespace = "")] - [Serializable] - public class UserData - { - public UserData() - { - AllowedApplications = new string[] {}; - Roles = new string[] {}; - } - - /// - /// Use this constructor to create/assign new UserData to the ticket - /// - /// - /// The current sessionId for the user - /// - public UserData(string sessionId) - { - SessionId = sessionId; - AllowedApplications = new string[] { }; - Roles = new string[] { }; - } - - /// - /// Gets or sets the session identifier. - /// - [DataMember(Name = "sessionId")] - public string SessionId { get; set; } - - /// - /// Gets or sets the security stamp. - /// - [DataMember(Name = "securityStamp")] - public string SecurityStamp { get; set; } - - [DataMember(Name = "id")] - public object Id { get; set; } - - [DataMember(Name = "roles")] - public string[] Roles { get; set; } - - [DataMember(Name = "username")] - public string Username { get; set; } - - [DataMember(Name = "name")] - public string RealName { get; set; } - - /// - /// The start nodes on the UserData object for the auth ticket contains all of the user's start nodes including ones assigned to their user groups - /// - [DataMember(Name = "startContent")] - public int[] StartContentNodes { get; set; } - - /// - /// The start nodes on the UserData object for the auth ticket contains all of the user's start nodes including ones assigned to their user groups - /// - [DataMember(Name = "startMedia")] - public int[] StartMediaNodes { get; set; } - - [DataMember(Name = "allowedApps")] - public string[] AllowedApplications { get; set; } - - [DataMember(Name = "culture")] - public string Culture { get; set; } - } -} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index cf1818f9e3..7790b8a769 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -1330,7 +1330,6 @@ - diff --git a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs index f71c73d26e..acb53a895a 100644 --- a/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs +++ b/src/Umbraco.Tests/Security/UmbracoBackOfficeIdentityTests.cs @@ -30,8 +30,9 @@ namespace Umbraco.Tests.Security //This is the id that 'identity' uses to check for the username new Claim(ClaimTypes.Name, "testing", ClaimValueTypes.String, TestIssuer, TestIssuer), new Claim(ClaimTypes.GivenName, "hello world", ClaimValueTypes.String, TestIssuer, TestIssuer), - new Claim(Constants.Security.StartContentNodeIdClaimType, "[-1]", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), - new Claim(Constants.Security.StartMediaNodeIdClaimType, "[5543]", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), + new Claim(Constants.Security.StartContentNodeIdClaimType, "-1", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), + new Claim(Constants.Security.StartMediaNodeIdClaimType, "5543", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), + new Claim(Constants.Security.StartMediaNodeIdClaimType, "5555", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), new Claim(Constants.Security.AllowedApplicationsClaimType, "content", ClaimValueTypes.String, TestIssuer, TestIssuer), new Claim(Constants.Security.AllowedApplicationsClaimType, "media", ClaimValueTypes.String, TestIssuer, TestIssuer), new Claim(ClaimTypes.Locality, "en-us", ClaimValueTypes.String, TestIssuer, TestIssuer), @@ -42,18 +43,18 @@ namespace Umbraco.Tests.Security var backofficeIdentity = UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity); - Assert.AreEqual("1234", backofficeIdentity.Id); + Assert.AreEqual(1234, backofficeIdentity.Id); Assert.AreEqual(sessionId, backofficeIdentity.SessionId); Assert.AreEqual(securityStamp, backofficeIdentity.SecurityStamp); Assert.AreEqual("testing", backofficeIdentity.Username); Assert.AreEqual("hello world", backofficeIdentity.RealName); Assert.AreEqual(1, backofficeIdentity.StartContentNodes.Length); - Assert.IsTrue(backofficeIdentity.StartMediaNodes.UnsortedSequenceEqual(new[] { 5543 })); + Assert.IsTrue(backofficeIdentity.StartMediaNodes.UnsortedSequenceEqual(new[] { 5543, 5555 })); Assert.IsTrue(new[] {"content", "media"}.SequenceEqual(backofficeIdentity.AllowedApplications)); Assert.AreEqual("en-us", backofficeIdentity.Culture); Assert.IsTrue(new[] { "admin" }.SequenceEqual(backofficeIdentity.Roles)); - Assert.AreEqual(11, backofficeIdentity.Claims.Count()); + Assert.AreEqual(12, backofficeIdentity.Claims.Count()); } [Test] @@ -90,102 +91,36 @@ namespace Umbraco.Tests.Security Assert.Throws(() => UmbracoBackOfficeIdentity.FromClaimsIdentity(claimsIdentity)); } - [Test] - public void Create_With_User_Data() - { - var sessionId = Guid.NewGuid().ToString(); - var userData = new UserData(sessionId) - { - SecurityStamp = sessionId, - AllowedApplications = new[] {"content", "media"}, - Culture = "en-us", - Id = 1234, - RealName = "hello world", - Roles = new[] {"admin"}, - StartMediaNodes = new[] { 654 }, - Username = "testing" - }; - - var identity = new UmbracoBackOfficeIdentity(userData); - - Assert.AreEqual(11, identity.Claims.Count()); - } [Test] public void Create_With_Claims_And_User_Data() { var sessionId = Guid.NewGuid().ToString(); - var userData = new UserData(sessionId) - { - SecurityStamp = sessionId, - AllowedApplications = new[] { "content", "media" }, - Culture = "en-us", - Id = 1234, - RealName = "hello world", - Roles = new[] { "admin" }, - StartMediaNodes = new[] { 654 }, - Username = "testing" - }; - + var claimsIdentity = new ClaimsIdentity(new[] { new Claim("TestClaim1", "test", ClaimValueTypes.Integer32, TestIssuer, TestIssuer), new Claim("TestClaim1", "test", ClaimValueTypes.Integer32, TestIssuer, TestIssuer) }); - var backofficeIdentity = new UmbracoBackOfficeIdentity(claimsIdentity, userData); - - Assert.AreEqual(13, backofficeIdentity.Claims.Count()); - } - - [Test] - public void Create_With_Forms_Ticket() - { - var sessionId = Guid.NewGuid().ToString(); - var userData = new UserData(sessionId) - { - SecurityStamp = sessionId, - AllowedApplications = new[] { "content", "media" }, - Culture = "en-us", - Id = 1234, - RealName = "hello world", - Roles = new[] { "admin" }, - StartMediaNodes = new[] { 654 }, - Username = "testing" - }; - - var ticket = new FormsAuthenticationTicket(1, userData.Username, DateTime.Now, DateTime.Now.AddDays(1), true, - JsonConvert.SerializeObject(userData)); - - var identity = new UmbracoBackOfficeIdentity(ticket); + var identity = new UmbracoBackOfficeIdentity(claimsIdentity, + 1234, "testing", "hello world", new[] { 654 }, new[] { 654 }, "en-us", sessionId, sessionId, new[] { "content", "media" }, new[] { "admin" }); Assert.AreEqual(12, identity.Claims.Count()); } + [Test] public void Clone() { var sessionId = Guid.NewGuid().ToString(); - var userData = new UserData(sessionId) - { - SecurityStamp = sessionId, - AllowedApplications = new[] { "content", "media" }, - Culture = "en-us", - Id = 1234, - RealName = "hello world", - Roles = new[] { "admin" }, - StartMediaNodes = new[] { 654 }, - Username = "testing" - }; - var ticket = new FormsAuthenticationTicket(1, userData.Username, DateTime.Now, DateTime.Now.AddDays(1), true, - JsonConvert.SerializeObject(userData)); - - var identity = new UmbracoBackOfficeIdentity(ticket); + var identity = new UmbracoBackOfficeIdentity( + 1234, "testing", "hello world", new[] { 654 }, new[] { 654 }, "en-us", sessionId, sessionId, new[] { "content", "media" }, new[] { "admin" }); var cloned = identity.Clone(); - Assert.AreEqual(12, cloned.Claims.Count()); + Assert.AreEqual(10, cloned.Claims.Count()); } } diff --git a/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs b/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs index c456475acc..ab473fd0c0 100644 --- a/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs +++ b/src/Umbraco.Tests/TestHelpers/ControllerTesting/AuthenticateEverythingMiddleware.cs @@ -26,16 +26,7 @@ namespace Umbraco.Tests.TestHelpers.ControllerTesting { var sessionId = Guid.NewGuid().ToString(); var identity = new UmbracoBackOfficeIdentity( - new UserData(sessionId) - { - SecurityStamp = sessionId, - Id = 0, - Roles = new[] { "admin" }, - AllowedApplications = new[] { "content", "media", "members" }, - Culture = "en-US", - RealName = "Admin", - Username = "admin" - }); + -1, "admin", "Admin", null, null, "en-US", sessionId, sessionId, new[] { "content", "media", "members" }, new[] { "admin" }); return Task.FromResult(new AuthenticationTicket(identity, new AuthenticationProperties() diff --git a/src/Umbraco.Web/Editors/CurrentUserController.cs b/src/Umbraco.Web/Editors/CurrentUserController.cs index a68ad4c813..a7d858c322 100644 --- a/src/Umbraco.Web/Editors/CurrentUserController.cs +++ b/src/Umbraco.Web/Editors/CurrentUserController.cs @@ -15,6 +15,7 @@ using System.Linq; using Newtonsoft.Json; using Umbraco.Core; using Umbraco.Core.Security; +using Umbraco.Web.Security; using Umbraco.Web.WebApi.Filters; diff --git a/src/Umbraco.Web/Security/AuthenticationExtensions.cs b/src/Umbraco.Web/Security/AuthenticationExtensions.cs new file mode 100644 index 0000000000..0eeb840496 --- /dev/null +++ b/src/Umbraco.Web/Security/AuthenticationExtensions.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading; +using System.Web; +using Microsoft.AspNet.Identity; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Newtonsoft.Json; +using Umbraco.Core; +using Umbraco.Core.Composing; +using Umbraco.Core.Configuration; +using Umbraco.Core.Security; +using Constants = Umbraco.Core.Constants; + +namespace Umbraco.Web.Security +{ + /// + /// Extensions to create and renew and remove authentication tickets for the Umbraco back office + /// + public static class AuthenticationExtensions + { + /// + /// This will check the ticket to see if it is valid, if it is it will set the current thread's user and culture + /// + /// + /// + /// If true will attempt to renew the ticket + public static bool AuthenticateCurrentRequest(this HttpContextBase http, AuthenticationTicket ticket, bool renewTicket) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + + //if there was a ticket, it's not expired, - it should not be renewed or its renewable + if (ticket?.Properties.ExpiresUtc != null && ticket.Properties.ExpiresUtc.Value > DateTimeOffset.UtcNow && (renewTicket == false || http.RenewUmbracoAuthTicket())) + { + try + { + //get the Umbraco user identity + if (!(ticket.Identity is UmbracoBackOfficeIdentity identity)) + throw new InvalidOperationException("The AuthenticationTicket specified does not contain the correct Identity type"); + + //set the principal object + var principal = new ClaimsPrincipal(identity); + + //It is actually not good enough to set this on the current app Context and the thread, it also needs + // to be set explicitly on the HttpContext.Current !! This is a strange web api thing that is actually + // an underlying fault of asp.net not propogating the User correctly. + if (HttpContext.Current != null) + { + HttpContext.Current.User = principal; + } + http.User = principal; + Thread.CurrentPrincipal = principal; + + //This is a back office request, we will also set the culture/ui culture + Thread.CurrentThread.CurrentCulture = + Thread.CurrentThread.CurrentUICulture = + new System.Globalization.CultureInfo(identity.Culture); + + return true; + } + catch (Exception ex) + { + if (ex is FormatException || ex is JsonReaderException) + { + //this will occur if the cookie data is invalid + http.UmbracoLogout(); + } + else + { + throw; + } + + } + } + + return false; + } + + + /// + /// This will return the current back office identity. + /// + /// + /// + /// If set to true and a back office identity is not found and not authenticated, this will attempt to authenticate the + /// request just as is done in the Umbraco module and then set the current identity if it is valid. + /// Just like in the UmbracoModule, if this is true then the user's culture will be assigned to the request. + /// + /// + /// Returns the current back office identity if an admin is authenticated otherwise null + /// + public static UmbracoBackOfficeIdentity GetCurrentIdentity(this HttpContextBase http, bool authenticateRequestIfNotFound) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + if (http.User == null) return null; //there's no user at all so no identity + + //If it's already a UmbracoBackOfficeIdentity + var backOfficeIdentity = http.User.GetUmbracoIdentity(); + if (backOfficeIdentity != null) return backOfficeIdentity; + + if (authenticateRequestIfNotFound == false) return null; + + //even if authenticateRequestIfNotFound is true we cannot continue if the request is actually authenticated + // which would mean something strange is going on that it is not an umbraco identity. + if (http.User.Identity.IsAuthenticated) return null; + + //So the user is not authed but we've been asked to do the auth if authenticateRequestIfNotFound = true, + // which might occur in old webforms style things or for routes that aren't included as a back office request. + // in this case, we are just reverting to authing using the cookie. + + // TODO: Even though this is in theory legacy, we have legacy bits laying around and we'd need to do the auth based on + // how the Module will eventually do it (by calling in to any registered authenticators). + + var ticket = http.GetUmbracoAuthTicket(); + if (http.AuthenticateCurrentRequest(ticket, true)) + { + //now we 'should have an umbraco identity + return http.User.Identity as UmbracoBackOfficeIdentity; + } + return null; + } + + /// + /// This will return the current back office identity. + /// + /// + /// + /// If set to true and a back office identity is not found and not authenticated, this will attempt to authenticate the + /// request just as is done in the Umbraco module and then set the current identity if it is valid + /// + /// + /// Returns the current back office identity if an admin is authenticated otherwise null + /// + internal static UmbracoBackOfficeIdentity GetCurrentIdentity(this HttpContext http, bool authenticateRequestIfNotFound) + { + if (http == null) throw new ArgumentNullException("http"); + return new HttpContextWrapper(http).GetCurrentIdentity(authenticateRequestIfNotFound); + } + + public static void UmbracoLogout(this HttpContextBase http) + { + if (http == null) throw new ArgumentNullException("http"); + Logout(http, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); + } + + /// + /// This clears the forms authentication cookie + /// + /// + internal static void UmbracoLogout(this HttpContext http) + { + if (http == null) throw new ArgumentNullException("http"); + new HttpContextWrapper(http).UmbracoLogout(); + } + + /// + /// This will force ticket renewal in the OWIN pipeline + /// + /// + /// + public static bool RenewUmbracoAuthTicket(this HttpContextBase http) + { + if (http == null) throw new ArgumentNullException("http"); + http.Items[Constants.Security.ForceReAuthFlag] = true; + return true; + } + + /// + /// This will force ticket renewal in the OWIN pipeline + /// + /// + /// + internal static bool RenewUmbracoAuthTicket(this HttpContext http) + { + if (http == null) throw new ArgumentNullException("http"); + http.Items[Constants.Security.ForceReAuthFlag] = true; + return true; + } + + ///// + ///// Creates the umbraco authentication ticket + ///// + ///// + ///// + //public static AuthenticationTicket CreateUmbracoAuthTicket(this HttpContextBase http, UserData userdata) + //{ + // //ONLY used by BasePage.doLogin! + + // if (http == null) throw new ArgumentNullException("http"); + // if (userdata == null) throw new ArgumentNullException("userdata"); + + // var userDataString = JsonConvert.SerializeObject(userdata); + // return CreateAuthTicketAndCookie( + // http, + // userdata.Username, + // userDataString, + // //use the configuration timeout - this is the same timeout that will be used when renewing the ticket. + // GlobalSettings.TimeOutInMinutes, + // //Umbraco has always persisted it's original cookie for 1 day so we'll keep it that way + // 1440, + // UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName, + // UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain); + //} + + /// + /// returns the number of seconds the user has until their auth session times out + /// + /// + /// + public static double GetRemainingAuthSeconds(this HttpContextBase http) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + var ticket = http.GetUmbracoAuthTicket(); + return ticket.GetRemainingAuthSeconds(); + } + + public static double GetRemainingAuthSeconds(this IOwinContext owinCtx) + { + if (owinCtx == null) throw new ArgumentNullException(nameof(owinCtx)); + var ticket = owinCtx.GetUmbracoAuthTicket(); + return ticket.GetRemainingAuthSeconds(); + } + + /// + /// returns the number of seconds the user has until their auth session times out + /// + /// + /// + public static double GetRemainingAuthSeconds(this AuthenticationTicket ticket) + { + var utcExpired = ticket?.Properties.ExpiresUtc; + if (utcExpired == null) return 0; + var secondsRemaining = utcExpired.Value.Subtract(DateTimeOffset.UtcNow).TotalSeconds; + return secondsRemaining; + } + + /// + /// Gets the umbraco auth ticket + /// + /// + /// + public static AuthenticationTicket GetUmbracoAuthTicket(this HttpContextBase http) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + return GetAuthTicket(http, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); + } + + internal static AuthenticationTicket GetUmbracoAuthTicket(this HttpContext http) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + return new HttpContextWrapper(http).GetUmbracoAuthTicket(); + } + + public static AuthenticationTicket GetUmbracoAuthTicket(this IOwinContext ctx) + { + if (ctx == null) throw new ArgumentNullException(nameof(ctx)); + return GetAuthTicket(ctx, UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName); + } + + /// + /// This clears the forms authentication cookie + /// + /// + /// + private static void Logout(this HttpContextBase http, string cookieName) + { + //We need to clear the sessionId from the database. This is legacy code to do any logging out and shouldn't really be used at all but in any case + //we need to make sure the session is cleared. Due to the legacy nature of this it means we need to use singletons + if (http.User != null) + { + var claimsIdentity = http.User.Identity as ClaimsIdentity; + if (claimsIdentity != null) + { + var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType); + Guid guidSession; + if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession)) + { + Current.Services.UserService.ClearLoginSession(guidSession); + } + } + } + + if (http == null) throw new ArgumentNullException("http"); + //clear the preview cookie and external login + var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName }; + foreach (var c in cookies) + { + //remove from the request + http.Request.Cookies.Remove(c); + + //expire from the response + var formsCookie = http.Response.Cookies[c]; + if (formsCookie != null) + { + //this will expire immediately and be removed from the browser + formsCookie.Expires = DateTime.Now.AddYears(-1); + } + else + { + //ensure there's def an expired cookie + http.Response.Cookies.Add(new HttpCookie(c) { Expires = DateTime.Now.AddYears(-1) }); + } + } + } + + private static AuthenticationTicket GetAuthTicket(this IOwinContext owinCtx, string cookieName) + { + var asDictionary = new Dictionary(); + foreach (var requestCookie in owinCtx.Request.Cookies) + { + var key = requestCookie.Key; + asDictionary[key] = requestCookie.Value; + } + + var secureFormat = owinCtx.GetUmbracoAuthTicketDataProtector(); + + //get the ticket + try + { + + return GetAuthTicket(secureFormat, asDictionary, cookieName); + } + catch (Exception) + { + owinCtx.Authentication.SignOut( + Constants.Security.BackOfficeAuthenticationType, + Constants.Security.BackOfficeExternalAuthenticationType); + return null; + } + } + + private static AuthenticationTicket GetAuthTicket(this HttpContextBase http, string cookieName) + { + var asDictionary = new Dictionary(); + for (var i = 0; i < http.Request.Cookies.Keys.Count; i++) + { + var key = http.Request.Cookies.Keys.Get(i); + asDictionary[key] = http.Request.Cookies[key].Value; + } + + var owinCtx = http.GetOwinContext(); + var secureFormat = owinCtx.GetUmbracoAuthTicketDataProtector(); + + //get the ticket + try + { + return GetAuthTicket(secureFormat, asDictionary, cookieName); + } + catch (Exception) + { + //occurs when decryption fails + http.Logout(cookieName); + return null; + } + } + + private static AuthenticationTicket GetAuthTicket(ISecureDataFormat secureDataFormat, IDictionary cookies, string cookieName) + { + if (cookies == null) throw new ArgumentNullException(nameof(cookies)); + + if (cookies.ContainsKey(cookieName) == false) return null; + + var formsCookie = cookies[cookieName]; + if (formsCookie == null) + { + return null; + } + //get the ticket + + return secureDataFormat.Unprotect(formsCookie); + } + } +} diff --git a/src/Umbraco.Web/Security/Identity/AppBuilderExtensions.cs b/src/Umbraco.Web/Security/Identity/AppBuilderExtensions.cs index 02a7208634..260149be84 100644 --- a/src/Umbraco.Web/Security/Identity/AppBuilderExtensions.cs +++ b/src/Umbraco.Web/Security/Identity/AppBuilderExtensions.cs @@ -8,6 +8,8 @@ using Microsoft.Owin.Extensions; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; using Microsoft.Owin.Security.Cookies; +using Microsoft.Owin.Security.DataHandler; +using Microsoft.Owin.Security.DataProtection; using Owin; using Umbraco.Core; using Umbraco.Core.Configuration; @@ -178,17 +180,21 @@ namespace Umbraco.Web.Security.Identity //don't apply if app is not ready if (runtimeState.Level != RuntimeLevel.Upgrade && runtimeState.Level != RuntimeLevel.Run) return app; - var getSecondsOptions = app.CreateUmbracoCookieAuthOptions( + var cookieAuthOptions = app.CreateUmbracoCookieAuthOptions( //This defines the explicit path read cookies from for this middleware - new[] {string.Format("{0}/backoffice/UmbracoApi/Authentication/GetRemainingTimeoutSeconds", GlobalSettings.Path)}); - getSecondsOptions.Provider = cookieOptions.Provider; + new[] {$"{GlobalSettings.Path}/backoffice/UmbracoApi/Authentication/GetRemainingTimeoutSeconds"}); + cookieAuthOptions.Provider = cookieOptions.Provider; //This is a custom middleware, we need to return the user's remaining logged in seconds app.Use( - getSecondsOptions, + cookieAuthOptions, UmbracoConfig.For.UmbracoSettings().Security, app.CreateLogger()); + //This is required so that we can read the auth ticket format outside of this pipeline + app.CreatePerOwinContext( + (options, context) => new UmbracoAuthTicketDataProtector(cookieOptions.TicketDataFormat)); + return app; } @@ -346,11 +352,19 @@ namespace Umbraco.Web.Security.Identity /// public static UmbracoBackOfficeCookieAuthOptions CreateUmbracoCookieAuthOptions(this IAppBuilder app, string[] explicitPaths = null) { + //this is how aspnet wires up the default AuthenticationTicket protector so we'll use the same code + var ticketDataFormat = new TicketDataFormat( + app.CreateDataProtector(typeof (CookieAuthenticationMiddleware).FullName, + Constants.Security.BackOfficeAuthenticationType, + "v1")); + var authOptions = new UmbracoBackOfficeCookieAuthOptions( explicitPaths, UmbracoConfig.For.UmbracoSettings().Security, GlobalSettings.TimeOutInMinutes, - GlobalSettings.UseSSL); + GlobalSettings.UseSSL, + ticketDataFormat); + return authOptions; } } diff --git a/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs b/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs index 98493db1c7..43c84c2ba9 100644 --- a/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs +++ b/src/Umbraco.Web/Security/Identity/BackOfficeCookieManager.cs @@ -6,7 +6,7 @@ using Microsoft.Owin; using Microsoft.Owin.Infrastructure; using Umbraco.Core; using Umbraco.Core.Configuration; -using Umbraco.Core.IO; +using Umbraco.Core.Security; namespace Umbraco.Web.Security.Identity { diff --git a/src/Umbraco.Web/Security/Identity/FormsAuthenticationSecureDataFormat.cs b/src/Umbraco.Web/Security/Identity/FormsAuthenticationSecureDataFormat.cs deleted file mode 100644 index 64da407021..0000000000 --- a/src/Umbraco.Web/Security/Identity/FormsAuthenticationSecureDataFormat.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Security.Claims; -using System.Web.Security; -using Microsoft.Owin; -using Microsoft.Owin.Security; -using Microsoft.Owin.Security.Cookies; -using Newtonsoft.Json; -using Owin; -using Umbraco.Core.Security; - -namespace Umbraco.Web.Security.Identity -{ - - /// - /// Custom secure format that uses the old FormsAuthentication format - /// - internal class FormsAuthenticationSecureDataFormat : ISecureDataFormat - { - private readonly int _loginTimeoutMinutes; - - public FormsAuthenticationSecureDataFormat(int loginTimeoutMinutes) - { - _loginTimeoutMinutes = loginTimeoutMinutes; - } - - public string Protect(AuthenticationTicket data) - { - var backofficeIdentity = (UmbracoBackOfficeIdentity)data.Identity; - var userDataString = JsonConvert.SerializeObject(backofficeIdentity.UserData); - - var ticket = new FormsAuthenticationTicket( - 5, - data.Identity.Name, - data.Properties.IssuedUtc.HasValue - ? data.Properties.IssuedUtc.Value.LocalDateTime - : DateTime.Now, - data.Properties.ExpiresUtc.HasValue - ? data.Properties.ExpiresUtc.Value.LocalDateTime - : DateTime.Now.AddMinutes(_loginTimeoutMinutes), - data.Properties.IsPersistent, - userDataString, - "/" - ); - - return FormsAuthentication.Encrypt(ticket); - } - - /// - /// Unprotects the cookie - /// - /// - /// - public AuthenticationTicket Unprotect(string protectedText) - { - FormsAuthenticationTicket decrypt; - try - { - decrypt = FormsAuthentication.Decrypt(protectedText); - if (decrypt == null) return null; - } - catch (Exception) - { - return null; - } - - UmbracoBackOfficeIdentity identity; - - try - { - identity = new UmbracoBackOfficeIdentity(decrypt); - } - catch (Exception) - { - //if it cannot be created return null, will be due to serialization errors in user data most likely due to corrupt cookies or cookies - //for previous versions of Umbraco - return null; - } - - var ticket = new AuthenticationTicket(identity, new AuthenticationProperties - { - ExpiresUtc = decrypt.Expiration.ToUniversalTime(), - IssuedUtc = decrypt.IssueDate.ToUniversalTime(), - IsPersistent = decrypt.IsPersistent, - AllowRefresh = true - }); - - return ticket; - } - } -} diff --git a/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs b/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs index 0efb0b66a6..075fe89cdc 100644 --- a/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs +++ b/src/Umbraco.Web/Security/Identity/GetUserSecondsMiddleWare.cs @@ -35,11 +35,9 @@ namespace Umbraco.Web.Security.Identity ILogger logger) : base(next) { - if (authOptions == null) throw new ArgumentNullException("authOptions"); - if (logger == null) throw new ArgumentNullException("logger"); - _authOptions = authOptions; + _authOptions = authOptions ?? throw new ArgumentNullException(nameof(authOptions)); _security = security; - _logger = logger; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public override async Task Invoke(IOwinContext context) @@ -49,7 +47,7 @@ namespace Umbraco.Web.Security.Identity if (request.Uri.Scheme.InvariantStartsWith("http") && request.Uri.AbsolutePath.InvariantEquals( - string.Format("{0}/backoffice/UmbracoApi/Authentication/GetRemainingTimeoutSeconds", GlobalSettings.Path))) + $"{GlobalSettings.Path}/backoffice/UmbracoApi/Authentication/GetRemainingTimeoutSeconds")) { var cookie = _authOptions.CookieManager.GetRequestCookie(context, _security.AuthCookieName); if (cookie.IsNullOrWhiteSpace() == false) diff --git a/src/Umbraco.Web/Security/Identity/UmbracoBackOfficeCookieAuthOptions.cs b/src/Umbraco.Web/Security/Identity/UmbracoBackOfficeCookieAuthOptions.cs index 714337fb2d..679e1c8b67 100644 --- a/src/Umbraco.Web/Security/Identity/UmbracoBackOfficeCookieAuthOptions.cs +++ b/src/Umbraco.Web/Security/Identity/UmbracoBackOfficeCookieAuthOptions.cs @@ -14,29 +14,19 @@ namespace Umbraco.Web.Security.Identity /// public sealed class UmbracoBackOfficeCookieAuthOptions : CookieAuthenticationOptions { - public int LoginTimeoutMinutes { get; private set; } - - public UmbracoBackOfficeCookieAuthOptions() - : this(UmbracoConfig.For.UmbracoSettings().Security, GlobalSettings.TimeOutInMinutes, GlobalSettings.UseSSL) - { - } - + public int LoginTimeoutMinutes { get; } + public UmbracoBackOfficeCookieAuthOptions( string[] explicitPaths, ISecuritySection securitySection, int loginTimeoutMinutes, bool forceSsl, - bool useLegacyFormsAuthDataFormat = true) + ISecureDataFormat secureDataFormat) { + var secureDataFormat1 = secureDataFormat ?? throw new ArgumentNullException(nameof(secureDataFormat)); LoginTimeoutMinutes = loginTimeoutMinutes; AuthenticationType = Constants.Security.BackOfficeAuthenticationType; - - if (useLegacyFormsAuthDataFormat) - { - //If this is not explicitly set it will fall back to the default automatically - TicketDataFormat = new FormsAuthenticationSecureDataFormat(LoginTimeoutMinutes); - } - + SlidingExpiration = true; ExpireTimeSpan = TimeSpan.FromMinutes(LoginTimeoutMinutes); CookieDomain = securitySection.AuthCookieDomain; @@ -45,19 +35,12 @@ namespace Umbraco.Web.Security.Identity CookieSecure = forceSsl ? CookieSecureOption.Always : CookieSecureOption.SameAsRequest; CookiePath = "/"; + TicketDataFormat = new UmbracoSecureDataFormat(LoginTimeoutMinutes, secureDataFormat1); + //Custom cookie manager so we can filter requests CookieManager = new BackOfficeCookieManager(Current.UmbracoContextAccessor, Current.RuntimeState, explicitPaths); } - - public UmbracoBackOfficeCookieAuthOptions( - ISecuritySection securitySection, - int loginTimeoutMinutes, - bool forceSsl, - bool useLegacyFormsAuthDataFormat = true) - : this(null, securitySection, loginTimeoutMinutes, forceSsl, useLegacyFormsAuthDataFormat) - { - } - + /// /// Creates the cookie options for saving the auth cookie /// @@ -66,8 +49,8 @@ namespace Umbraco.Web.Security.Identity /// public CookieOptions CreateRequestCookieOptions(IOwinContext ctx, AuthenticationTicket ticket) { - if (ctx == null) throw new ArgumentNullException("ctx"); - if (ticket == null) throw new ArgumentNullException("ticket"); + if (ctx == null) throw new ArgumentNullException(nameof(ctx)); + if (ticket == null) throw new ArgumentNullException(nameof(ticket)); var issuedUtc = ticket.Properties.IssuedUtc ?? SystemClock.UtcNow; var expiresUtc = ticket.Properties.ExpiresUtc ?? issuedUtc.Add(ExpireTimeSpan); diff --git a/src/Umbraco.Web/Security/Identity/UmbracoSecureDataFormat.cs b/src/Umbraco.Web/Security/Identity/UmbracoSecureDataFormat.cs new file mode 100644 index 0000000000..2676a5ee25 --- /dev/null +++ b/src/Umbraco.Web/Security/Identity/UmbracoSecureDataFormat.cs @@ -0,0 +1,83 @@ +using System; +using System.Security.Claims; +using System.Web.Security; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Cookies; +using Newtonsoft.Json; +using Owin; +using Umbraco.Core.Security; + +namespace Umbraco.Web.Security.Identity +{ + + /// + /// Custom secure format that ensures the Identity in the ticket is and not just a ClaimsIdentity + /// + internal class UmbracoSecureDataFormat : ISecureDataFormat + { + private readonly int _loginTimeoutMinutes; + private readonly ISecureDataFormat _ticketDataFormat; + + public UmbracoSecureDataFormat(int loginTimeoutMinutes, ISecureDataFormat ticketDataFormat) + { + _loginTimeoutMinutes = loginTimeoutMinutes; + _ticketDataFormat = ticketDataFormat ?? throw new ArgumentNullException(nameof(ticketDataFormat)); + } + + public string Protect(AuthenticationTicket data) + { + var backofficeIdentity = (UmbracoBackOfficeIdentity)data.Identity; + + //create a new ticket based on the passed in tickets details, however, we'll adjust the expires utc based on the specified timeout mins + var ticket = new AuthenticationTicket(backofficeIdentity, + new AuthenticationProperties(data.Properties.Dictionary) + { + IssuedUtc = data.Properties.IssuedUtc, + ExpiresUtc = data.Properties.ExpiresUtc ?? DateTimeOffset.UtcNow.AddMinutes(_loginTimeoutMinutes), + AllowRefresh = data.Properties.AllowRefresh, + IsPersistent = data.Properties.IsPersistent, + RedirectUri = data.Properties.RedirectUri + }); + + return _ticketDataFormat.Protect(ticket); + } + + /// + /// Unprotects the cookie + /// + /// + /// + public AuthenticationTicket Unprotect(string protectedText) + { + AuthenticationTicket decrypt; + try + { + decrypt = _ticketDataFormat.Unprotect(protectedText); + if (decrypt == null) return null; + } + catch (Exception) + { + return null; + } + + UmbracoBackOfficeIdentity identity; + + try + { + identity = UmbracoBackOfficeIdentity.FromClaimsIdentity(decrypt.Identity); + } + catch (Exception) + { + //if it cannot be created return null, will be due to serialization errors in user data most likely due to corrupt cookies or cookies + //for previous versions of Umbraco + return null; + } + + //return the ticket with a UmbracoBackOfficeIdentity + var ticket = new AuthenticationTicket(identity, decrypt.Properties); + + return ticket; + } + } +} diff --git a/src/Umbraco.Web/Security/LegacyDefaultAppMapping.cs b/src/Umbraco.Web/Security/LegacyDefaultAppMapping.cs deleted file mode 100644 index 54ed9aea33..0000000000 --- a/src/Umbraco.Web/Security/LegacyDefaultAppMapping.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace Umbraco.Web.Security -{ - /// - /// This is used specifically to assign a default 'app' to a particular section in order to validate the - /// currently logged in user's allowed applications - /// - /// - /// This relates to these issues: - /// http://issues.umbraco.org/issue/U4-2021 - /// http://issues.umbraco.org/issue/U4-529 - /// - /// In order to fix these issues we need to pass in an 'app' parameter but since we don't want to break compatibility - /// we will create this mapping to map a 'default application' to a section action (like creating or deleting) - /// - public static class LegacyDefaultAppMapping - { - /// - /// Constructor that assigns all initial known mappings - /// - static LegacyDefaultAppMapping() - { - } - - private static readonly ConcurrentDictionary NodeTypeAliasMapping = new ConcurrentDictionary(); - - /// - /// Adds the default app mapping to the node type - /// - /// The nodeType is the same nodeType found in the UI.xml - /// The default app associated with this nodeType if the 'app' parameter was not detected - public static void AddNodeTypeMappingForCreateDialog(string nodeType, string defaultApp) - { - NodeTypeAliasMapping.AddOrUpdate(nodeType, s => defaultApp, (s, s1) => defaultApp); - } - - internal static string GetDefaultAppForCreateDialog(string nodeTypeAlias) - { - string app; - return NodeTypeAliasMapping.TryGetValue(nodeTypeAlias, out app) ? app : null; - } - - } -} diff --git a/src/Umbraco.Web/Security/OwinExtensions.cs b/src/Umbraco.Web/Security/OwinExtensions.cs index 0df0c28cf6..c9d3c56513 100644 --- a/src/Umbraco.Web/Security/OwinExtensions.cs +++ b/src/Umbraco.Web/Security/OwinExtensions.cs @@ -1,7 +1,7 @@ -using System; -using System.Web; +using System.Web; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin; +using Microsoft.Owin.Security; using Umbraco.Core; using Umbraco.Core.Models.Identity; using Umbraco.Core.Security; @@ -11,16 +11,14 @@ namespace Umbraco.Web.Security { internal static class OwinExtensions { - /// - /// Nasty little hack to get httpcontextbase from an owin context + /// Gets the for the Umbraco back office cookie /// /// /// - internal static Attempt TryGetHttpContext(this IOwinContext owinContext) + internal static ISecureDataFormat GetUmbracoAuthTicketDataProtector(this IOwinContext owinContext) { - var ctx = owinContext.Get(typeof(HttpContextBase).FullName); - return ctx == null ? Attempt.Fail() : Attempt.Succeed(ctx); + return owinContext.Get().Protector; } } diff --git a/src/Umbraco.Web/Security/UmbracoAuthTicketDataProtector.cs b/src/Umbraco.Web/Security/UmbracoAuthTicketDataProtector.cs new file mode 100644 index 0000000000..c65c010204 --- /dev/null +++ b/src/Umbraco.Web/Security/UmbracoAuthTicketDataProtector.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.Owin.Security; +using Umbraco.Core; + +namespace Umbraco.Web.Security +{ + /// + /// This is used so that we can retrive the auth ticket protector from an IOwinContext + /// + internal class UmbracoAuthTicketDataProtector : DisposableObjectSlim + { + public UmbracoAuthTicketDataProtector(ISecureDataFormat protector) + { + Protector = protector ?? throw new ArgumentNullException(nameof(protector)); + } + + public ISecureDataFormat Protector { get; } + } +} diff --git a/src/Umbraco.Web/UI/Pages/UmbracoEnsuredPage.cs b/src/Umbraco.Web/UI/Pages/UmbracoEnsuredPage.cs index 5fcc730f90..3c100a839e 100644 --- a/src/Umbraco.Web/UI/Pages/UmbracoEnsuredPage.cs +++ b/src/Umbraco.Web/UI/Pages/UmbracoEnsuredPage.cs @@ -11,6 +11,7 @@ using Umbraco.Core.Models; using Umbraco.Core.Models.Entities; using Umbraco.Core.Security; using Umbraco.Web.Composing; +using Umbraco.Web.Security; using Umbraco.Web._Legacy.Actions; namespace Umbraco.Web.UI.Pages diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 9a8d424708..35c669ebd7 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -404,6 +404,9 @@ + + + @@ -769,7 +772,6 @@ - @@ -1084,7 +1086,6 @@ - diff --git a/src/Umbraco.Web/WebApi/Filters/UmbracoUserTimeoutFilterAttribute.cs b/src/Umbraco.Web/WebApi/Filters/UmbracoUserTimeoutFilterAttribute.cs index 2d938eb6e8..0d54ed99eb 100644 --- a/src/Umbraco.Web/WebApi/Filters/UmbracoUserTimeoutFilterAttribute.cs +++ b/src/Umbraco.Web/WebApi/Filters/UmbracoUserTimeoutFilterAttribute.cs @@ -1,6 +1,8 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Web.Http.Filters; using Umbraco.Core.Security; +using Umbraco.Web.Security; namespace Umbraco.Web.WebApi.Filters { @@ -17,12 +19,13 @@ namespace Umbraco.Web.WebApi.Filters //this can occur if an error has already occurred. if (actionExecutedContext.Response == null) return; - + var httpContextAttempt = actionExecutedContext.Request.TryGetHttpContext(); if (httpContextAttempt.Success) - { + { + var ticket = httpContextAttempt.Result.GetUmbracoAuthTicket(); - if (ticket != null && ticket.Expired == false) + if (ticket?.Properties.ExpiresUtc != null && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow) { var remainingSeconds = httpContextAttempt.Result.GetRemainingAuthSeconds(); actionExecutedContext.Response.Headers.Add("X-Umb-User-Seconds", remainingSeconds.ToString(CultureInfo.InvariantCulture)); diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/CustomTreeService.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/CustomTreeService.cs index 70b08fbf0f..f3143bad30 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/CustomTreeService.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/CustomTreeService.cs @@ -11,6 +11,7 @@ using umbraco.cms.businesslogic; using umbraco.cms.presentation.Trees; using umbraco.controls.Tree; using Umbraco.Core.Services; +using Umbraco.Web.Security; using Umbraco.Web.WebServices; namespace umbraco.controls.Tree diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/TreeControl.ascx.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/TreeControl.ascx.cs index 385c4c1843..67d82f327f 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/TreeControl.ascx.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/controls/Tree/TreeControl.ascx.cs @@ -13,6 +13,7 @@ using System.Drawing; using System.Linq; using Umbraco.Core; using Umbraco.Core.Services; +using Umbraco.Web.Security; namespace umbraco.controls.Tree {