From fc53503d1d9beda629e5723a1bd09d0a967584a8 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Mon, 21 Jul 2014 10:42:04 -0700 Subject: [PATCH] Add SecurityStampValidator --- samples/IdentitySample.Mvc/Startup.cs | 7 + ...osoft.AspNet.Identity.Authentication.kproj | 2 +- .../SecurityStampValidator.cs | 92 +++++++++++ .../ClaimsIdentityExtensions.cs | 59 +++++++ .../Microsoft.AspNet.Identity.kproj | 1 + .../SignInManager.cs | 25 ++- ....AspNet.Identity.Authentication.Test.kproj | 4 +- .../SecurityStampValidatorTest.cs | 147 ++++++++++++++++++ .../ClaimsIdentityExtensionsTest.cs | 77 +++++++++ .../Microsoft.AspNet.Identity.Test.kproj | 1 + 10 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.AspNet.Identity.Authentication/SecurityStampValidator.cs create mode 100644 src/Microsoft.AspNet.Identity/ClaimsIdentityExtensions.cs create mode 100644 test/Microsoft.AspNet.Identity.Authentication.Test/SecurityStampValidatorTest.cs create mode 100644 test/Microsoft.AspNet.Identity.Test/ClaimsIdentityExtensionsTest.cs diff --git a/samples/IdentitySample.Mvc/Startup.cs b/samples/IdentitySample.Mvc/Startup.cs index 4737f2e12c..ae402d5c0a 100644 --- a/samples/IdentitySample.Mvc/Startup.cs +++ b/samples/IdentitySample.Mvc/Startup.cs @@ -2,12 +2,14 @@ using Microsoft.AspNet.Builder; using Microsoft.AspNet.Diagnostics; using Microsoft.AspNet.Http; using Microsoft.AspNet.Identity; +using Microsoft.AspNet.Identity.Authentication; using Microsoft.AspNet.Routing; using Microsoft.AspNet.Security.Cookies; using Microsoft.Data.Entity; using Microsoft.Framework.ConfigurationModel; using Microsoft.Framework.DependencyInjection; using IdentitySample.Models; +using System; namespace IdentitySamples { @@ -65,6 +67,11 @@ namespace IdentitySamples { AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, LoginPath = new PathString("/Account/Login"), + Notifications = new CookieAuthenticationNotifications + { + OnValidateIdentity = SecurityStampValidator.OnValidateIdentity( + validateInterval: TimeSpan.FromMinutes(0)) + } }); app.UseTwoFactorSignInCookies(); diff --git a/src/Microsoft.AspNet.Identity.Authentication/Microsoft.AspNet.Identity.Authentication.kproj b/src/Microsoft.AspNet.Identity.Authentication/Microsoft.AspNet.Identity.Authentication.kproj index bd58fb80d4..0d22ba817a 100644 --- a/src/Microsoft.AspNet.Identity.Authentication/Microsoft.AspNet.Identity.Authentication.kproj +++ b/src/Microsoft.AspNet.Identity.Authentication/Microsoft.AspNet.Identity.Authentication.kproj @@ -20,10 +20,10 @@ + - \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity.Authentication/SecurityStampValidator.cs b/src/Microsoft.AspNet.Identity.Authentication/SecurityStampValidator.cs new file mode 100644 index 0000000000..6d760675ab --- /dev/null +++ b/src/Microsoft.AspNet.Identity.Authentication/SecurityStampValidator.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNet.Security.Cookies; +using Microsoft.Framework.DependencyInjection; + +namespace Microsoft.AspNet.Identity.Authentication +{ + /// + /// Static helper class used to configure a CookieAuthenticationProvider to validate a cookie against a user's security + /// stamp + /// + public static class SecurityStampValidator + { + /// + /// Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security + /// stamp after validateInterval + /// Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new + /// ClaimsIdentity + /// + /// + /// + /// + /// + public static Func OnValidateIdentity( + TimeSpan validateInterval) + where TUser : class + { + return OnValidateIdentity(validateInterval, id => id.GetUserId()); + } + + /// + /// Can be used as the ValidateIdentity method for a CookieAuthenticationProvider which will check a user's security + /// stamp after validateInterval + /// Rejects the identity if the stamp changes, and otherwise will call regenerateIdentity to sign in a new + /// ClaimsIdentity + /// + /// + /// + /// + /// + /// + /// + public static Func OnValidateIdentity( + TimeSpan validateInterval, + Func getUserIdCallback) + where TUser : class + { + return async context => + { + var currentUtc = DateTimeOffset.UtcNow; + if (context.Options != null && context.Options.SystemClock != null) + { + currentUtc = context.Options.SystemClock.UtcNow; + } + var issuedUtc = context.Properties.IssuedUtc; + + // Only validate if enough time has elapsed + var validate = (issuedUtc == null); + if (issuedUtc != null) + { + var timeElapsed = currentUtc.Subtract(issuedUtc.Value); + validate = timeElapsed > validateInterval; + } + if (validate) + { + var manager = context.HttpContext.ApplicationServices.GetService>(); + var userId = getUserIdCallback(context.Identity); + var user = await manager.ValidateSecurityStamp(context.Identity, userId); + if (user != null) + { + bool isPersistent = false; + if (context.Properties != null) + { + isPersistent = context.Properties.IsPersistent; + } + await manager.SignInAsync(user, isPersistent); + } + else + { + context.RejectIdentity(); + manager.SignOut(); + } + } + }; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/ClaimsIdentityExtensions.cs b/src/Microsoft.AspNet.Identity/ClaimsIdentityExtensions.cs new file mode 100644 index 0000000000..63cd7b03f3 --- /dev/null +++ b/src/Microsoft.AspNet.Identity/ClaimsIdentityExtensions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Claims; + +namespace System.Security.Principal +{ + /// + /// Extensions making it easier to get the user name/user id claims off of an identity + /// + public static class ClaimsIdentityExtensions + { + /// + /// Return the user name using the UserNameClaimType + /// + /// + /// + public static string GetUserName(this IIdentity identity) + { + if (identity == null) + { + throw new ArgumentNullException("identity"); + } + var ci = identity as ClaimsIdentity; + return ci != null ? ci.FindFirstValue(ClaimsIdentity.DefaultNameClaimType) : null; + } + + /// + /// Return the user id using the UserIdClaimType + /// + /// + /// + public static string GetUserId(this IIdentity identity) + { + if (identity == null) + { + throw new ArgumentNullException("identity"); + } + var ci = identity as ClaimsIdentity; + return ci != null ? ci.FindFirstValue(ClaimTypes.NameIdentifier) : null; + } + + /// + /// Return the claim value for the first claim with the specified type if it exists, null otherwise + /// + /// + /// + /// + public static string FindFirstValue(this ClaimsIdentity identity, string claimType) + { + if (identity == null) + { + throw new ArgumentNullException("identity"); + } + var claim = identity.FindFirst(claimType); + return claim != null ? claim.Value : null; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj b/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj index 27285c8215..d418832fbb 100644 --- a/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj +++ b/src/Microsoft.AspNet.Identity/Microsoft.AspNet.Identity.kproj @@ -21,6 +21,7 @@ + diff --git a/src/Microsoft.AspNet.Identity/SignInManager.cs b/src/Microsoft.AspNet.Identity/SignInManager.cs index c063b2049a..581f8edbfe 100644 --- a/src/Microsoft.AspNet.Identity/SignInManager.cs +++ b/src/Microsoft.AspNet.Identity/SignInManager.cs @@ -3,6 +3,7 @@ using System; using System.Security.Claims; +using System.Security.Principal; using System.Threading.Tasks; namespace Microsoft.AspNet.Identity @@ -51,12 +52,34 @@ namespace Microsoft.AspNet.Identity } // TODO: Should this be async? - public void SignOut() + public virtual void SignOut() { // REVIEW: need a new home for this option config? AuthenticationManager.SignOut(UserManager.Options.ClaimsIdentity.AuthenticationType); } + /// + /// Validates that the claims identity has a security stamp matching the users + /// Returns the user if it matches, null otherwise + /// + /// + /// + /// + public virtual async Task ValidateSecurityStamp(ClaimsIdentity identity, string userId) + { + var user = await UserManager.FindByIdAsync(userId); + if (user != null && UserManager.SupportsUserSecurityStamp) + { + var securityStamp = + identity.FindFirstValue(UserManager.Options.ClaimsIdentity.SecurityStampClaimType); + if (securityStamp == await UserManager.GetSecurityStampAsync(user)) + { + return user; + } + } + return null; + } + public virtual async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool shouldLockout) { diff --git a/test/Microsoft.AspNet.Identity.Authentication.Test/Microsoft.AspNet.Identity.Authentication.Test.kproj b/test/Microsoft.AspNet.Identity.Authentication.Test/Microsoft.AspNet.Identity.Authentication.Test.kproj index 53a096b7a7..3a15c4b396 100644 --- a/test/Microsoft.AspNet.Identity.Authentication.Test/Microsoft.AspNet.Identity.Authentication.Test.kproj +++ b/test/Microsoft.AspNet.Identity.Authentication.Test/Microsoft.AspNet.Identity.Authentication.Test.kproj @@ -22,7 +22,7 @@ - + - + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Identity.Authentication.Test/SecurityStampValidatorTest.cs b/test/Microsoft.AspNet.Identity.Authentication.Test/SecurityStampValidatorTest.cs new file mode 100644 index 0000000000..589ee700a7 --- /dev/null +++ b/test/Microsoft.AspNet.Identity.Authentication.Test/SecurityStampValidatorTest.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Security; +using Microsoft.AspNet.Identity.Test; +using Microsoft.AspNet.Security; +using Microsoft.AspNet.Security.Cookies; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.DependencyInjection.Fallback; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Identity.Authentication.Test +{ + public class SecurityStampTest + { + [Fact] + public async Task OnValidateIdentityThrowsWithEmptyServiceCollection() + { + var httpContext = new Mock(); + httpContext.Setup(c => c.ApplicationServices).Returns(new ServiceCollection().BuildServiceProvider()); + var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie); + var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow }); + var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions()); + await Assert.ThrowsAsync(() => SecurityStampValidator.OnValidateIdentity(TimeSpan.Zero).Invoke(context)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task OnValidateIdentityTestSuccess(bool isPersistent) + { + var user = new IdentityUser("test"); + var httpContext = new Mock(); + var userManager = MockHelpers.MockUserManager(); + var authManager = new Mock(); + var claimsManager = new Mock>(); + var signInManager = new Mock>(userManager.Object, + authManager.Object, claimsManager.Object); + signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny(), user.Id)).ReturnsAsync(user).Verifiable(); + signInManager.Setup(s => s.SignInAsync(user, isPersistent)).Returns(Task.FromResult(0)).Verifiable(); + var services = new ServiceCollection(); + services.AddInstance(signInManager.Object); + httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider()); + var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id)); + + var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow, IsPersistent = isPersistent }); + var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions()); + Assert.NotNull(context.Properties); + Assert.NotNull(context.Options); + Assert.NotNull(context.Identity); + await + SecurityStampValidator.OnValidateIdentity(TimeSpan.Zero).Invoke(context); + Assert.NotNull(context.Identity); + signInManager.VerifyAll(); + } + + [Fact] + public async Task OnValidateIdentityRejectsWhenValidateSecurityStampFails() + { + var user = new IdentityUser("test"); + var httpContext = new Mock(); + var userManager = MockHelpers.MockUserManager(); + var authManager = new Mock(); + var claimsManager = new Mock>(); + var signInManager = new Mock>(userManager.Object, + authManager.Object, claimsManager.Object); + signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny(), user.Id)).ReturnsAsync(null).Verifiable(); + var services = new ServiceCollection(); + services.AddInstance(signInManager.Object); + httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider()); + var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id)); + + var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow }); + var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions()); + Assert.NotNull(context.Properties); + Assert.NotNull(context.Options); + Assert.NotNull(context.Identity); + await + SecurityStampValidator.OnValidateIdentity(TimeSpan.Zero).Invoke(context); + Assert.Null(context.Identity); + signInManager.VerifyAll(); + } + + [Fact] + public async Task OnValidateIdentityRejectsWhenNoIssuedUtc() + { + var user = new IdentityUser("test"); + var httpContext = new Mock(); + var userManager = MockHelpers.MockUserManager(); + var authManager = new Mock(); + var claimsManager = new Mock>(); + var signInManager = new Mock>(userManager.Object, + authManager.Object, claimsManager.Object); + signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny(), user.Id)).ReturnsAsync(null).Verifiable(); + var services = new ServiceCollection(); + services.AddInstance(signInManager.Object); + httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider()); + var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id)); + + var ticket = new AuthenticationTicket(id, new AuthenticationProperties()); + var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions()); + Assert.NotNull(context.Properties); + Assert.NotNull(context.Options); + Assert.NotNull(context.Identity); + await + SecurityStampValidator.OnValidateIdentity(TimeSpan.Zero).Invoke(context); + Assert.Null(context.Identity); + signInManager.VerifyAll(); + } + + [Fact] + public async Task OnValidateIdentityDoesNotRejectsWhenNotExpired() + { + var user = new IdentityUser("test"); + var httpContext = new Mock(); + var userManager = MockHelpers.MockUserManager(); + var authManager = new Mock(); + var claimsManager = new Mock>(); + var signInManager = new Mock>(userManager.Object, + authManager.Object, claimsManager.Object); + signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny(), user.Id)).Throws(new Exception("Shouldn't be called")); + signInManager.Setup(s => s.SignInAsync(user, false)).Throws(new Exception("Shouldn't be called")); + var services = new ServiceCollection(); + services.AddInstance(signInManager.Object); + httpContext.Setup(c => c.ApplicationServices).Returns(services.BuildServiceProvider()); + var id = new ClaimsIdentity(DefaultAuthenticationTypes.ApplicationCookie); + id.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id)); + + var ticket = new AuthenticationTicket(id, new AuthenticationProperties { IssuedUtc = DateTimeOffset.UtcNow }); + var context = new CookieValidateIdentityContext(httpContext.Object, ticket, new CookieAuthenticationOptions()); + Assert.NotNull(context.Properties); + Assert.NotNull(context.Options); + Assert.NotNull(context.Identity); + await + SecurityStampValidator.OnValidateIdentity(TimeSpan.FromDays(1)).Invoke(context); + Assert.NotNull(context.Identity); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Identity.Test/ClaimsIdentityExtensionsTest.cs b/test/Microsoft.AspNet.Identity.Test/ClaimsIdentityExtensionsTest.cs new file mode 100644 index 0000000000..0bec49649a --- /dev/null +++ b/test/Microsoft.AspNet.Identity.Test/ClaimsIdentityExtensionsTest.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Claims; +using System.Security.Principal; +using Xunit; + +namespace Microsoft.AspNet.Identity.Test +{ + public class ClaimsIdentityExtensionsTest + { + public const string ExternalAuthenticationType = "TestExternalAuth"; + + [Fact] + public void IdentityNullCheckTest() + { + IIdentity identity = null; + Assert.Throws("identity", () => identity.GetUserId()); + Assert.Throws("identity", () => identity.GetUserName()); + ClaimsIdentity claimsIdentity = null; + Assert.Throws("identity", () => claimsIdentity.FindFirstValue(null)); + } + + [Fact] + public void IdentityNullIfNotClaimsIdentityTest() + { + IIdentity identity = new TestIdentity(); + Assert.Null(identity.GetUserId()); + Assert.Null(identity.GetUserName()); + } + + [Fact] + public void UserNameAndIdTest() + { + var id = CreateTestExternalIdentity(); + Assert.Equal("NameIdentifier", id.GetUserId()); + Assert.Equal("Name", id.GetUserName()); + } + + [Fact] + public void IdentityExtensionsFindFirstValueNullIfUnknownTest() + { + var id = CreateTestExternalIdentity(); + Assert.Null(id.FindFirstValue("bogus")); + } + + private static ClaimsIdentity CreateTestExternalIdentity() + { + return new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, "NameIdentifier", null, ExternalAuthenticationType), + new Claim(ClaimTypes.Name, "Name") + }, + ExternalAuthenticationType); + } + + private class TestIdentity : IIdentity + { + public string AuthenticationType + { + get { throw new NotImplementedException(); } + } + + public bool IsAuthenticated + { + get { throw new NotImplementedException(); } + } + + public string Name + { + get { throw new NotImplementedException(); } + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Identity.Test/Microsoft.AspNet.Identity.Test.kproj b/test/Microsoft.AspNet.Identity.Test/Microsoft.AspNet.Identity.Test.kproj index 969ef2c0da..9ae4a17904 100644 --- a/test/Microsoft.AspNet.Identity.Test/Microsoft.AspNet.Identity.Test.kproj +++ b/test/Microsoft.AspNet.Identity.Test/Microsoft.AspNet.Identity.Test.kproj @@ -23,6 +23,7 @@ +