Add SecurityStampValidator

This commit is contained in:
Hao Kung 2014-07-21 10:42:04 -07:00
parent e74496c079
commit fc53503d1d
10 changed files with 411 additions and 4 deletions

View File

@ -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<ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(0))
}
});
app.UseTwoFactorSignInCookies();

View File

@ -20,10 +20,10 @@
<Content Include="project.json" />
</ItemGroup>
<ItemGroup>
<Compile Include="SecurityStampValidator.cs" />
<Compile Include="HttpAuthenticationManager.cs" />
<Compile Include="IdentityBuilderExtensions.cs" />
<Compile Include="BuilderExtensions.cs" />
<Compile Include="IdentityExtensions.cs" />
</ItemGroup>
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.targets" Condition="'$(VSToolsPath)' != ''" />
</Project>

View File

@ -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
{
/// <summary>
/// Static helper class used to configure a CookieAuthenticationProvider to validate a cookie against a user's security
/// stamp
/// </summary>
public static class SecurityStampValidator
{
/// <summary>
/// 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
/// </summary>
/// <typeparam name="TUser"></typeparam>
/// <param name="validateInterval"></param>
/// <param name="regenerateIdentity"></param>
/// <returns></returns>
public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TUser>(
TimeSpan validateInterval)
where TUser : class
{
return OnValidateIdentity<TUser>(validateInterval, id => id.GetUserId());
}
/// <summary>
/// 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
/// </summary>
/// <typeparam name="TUser"></typeparam>
/// <typeparam name="TKey"></typeparam>
/// <param name="validateInterval"></param>
/// <param name="regenerateIdentityCallback"></param>
/// <param name="getUserIdCallback"></param>
/// <returns></returns>
public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TUser>(
TimeSpan validateInterval,
Func<ClaimsIdentity, string> 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<SignInManager<TUser>>();
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();
}
}
};
}
}
}

View File

@ -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
{
/// <summary>
/// Extensions making it easier to get the user name/user id claims off of an identity
/// </summary>
public static class ClaimsIdentityExtensions
{
/// <summary>
/// Return the user name using the UserNameClaimType
/// </summary>
/// <param name="identity"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Return the user id using the UserIdClaimType
/// </summary>
/// <param name="identity"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Return the claim value for the first claim with the specified type if it exists, null otherwise
/// </summary>
/// <param name="identity"></param>
/// <param name="claimType"></param>
/// <returns></returns>
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;
}
}
}

View File

@ -21,6 +21,7 @@
<Content Include="Resources.resx" />
</ItemGroup>
<ItemGroup>
<Compile Include="ClaimsIdentityExtensions.cs" />
<Compile Include="ClaimsIdentityFactory.cs" />
<Compile Include="ClaimsIdentityOptions.cs" />
<Compile Include="Crypto.cs" />

View File

@ -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);
}
/// <summary>
/// Validates that the claims identity has a security stamp matching the users
/// Returns the user if it matches, null otherwise
/// </summary>
/// <param name="identity"></param>
/// <param name="userId"></param>
/// <returns></returns>
public virtual async Task<TUser> 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<SignInStatus> PasswordSignInAsync(string userName, string password,
bool isPersistent, bool shouldLockout)
{

View File

@ -22,7 +22,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="HttpSignInTest.cs" />
<Compile Include="IdentityExtensionsTest.cs" />
<Compile Include="SecurityStampValidatorTest.cs" />
</ItemGroup>
<Import Project="$(VSToolsPath)\AspNet\Microsoft.Web.AspNet.targets" Condition="'$(VSToolsPath)' != ''" />
</Project>
</Project>

View File

@ -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>();
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<Exception>(() => SecurityStampValidator.OnValidateIdentity<IdentityUser>(TimeSpan.Zero).Invoke(context));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task OnValidateIdentityTestSuccess(bool isPersistent)
{
var user = new IdentityUser("test");
var httpContext = new Mock<HttpContext>();
var userManager = MockHelpers.MockUserManager<IdentityUser>();
var authManager = new Mock<IAuthenticationManager>();
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
authManager.Object, claimsManager.Object);
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), 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<IdentityUser>(TimeSpan.Zero).Invoke(context);
Assert.NotNull(context.Identity);
signInManager.VerifyAll();
}
[Fact]
public async Task OnValidateIdentityRejectsWhenValidateSecurityStampFails()
{
var user = new IdentityUser("test");
var httpContext = new Mock<HttpContext>();
var userManager = MockHelpers.MockUserManager<IdentityUser>();
var authManager = new Mock<IAuthenticationManager>();
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
authManager.Object, claimsManager.Object);
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), 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<IdentityUser>(TimeSpan.Zero).Invoke(context);
Assert.Null(context.Identity);
signInManager.VerifyAll();
}
[Fact]
public async Task OnValidateIdentityRejectsWhenNoIssuedUtc()
{
var user = new IdentityUser("test");
var httpContext = new Mock<HttpContext>();
var userManager = MockHelpers.MockUserManager<IdentityUser>();
var authManager = new Mock<IAuthenticationManager>();
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
authManager.Object, claimsManager.Object);
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), 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<IdentityUser>(TimeSpan.Zero).Invoke(context);
Assert.Null(context.Identity);
signInManager.VerifyAll();
}
[Fact]
public async Task OnValidateIdentityDoesNotRejectsWhenNotExpired()
{
var user = new IdentityUser("test");
var httpContext = new Mock<HttpContext>();
var userManager = MockHelpers.MockUserManager<IdentityUser>();
var authManager = new Mock<IAuthenticationManager>();
var claimsManager = new Mock<IClaimsIdentityFactory<IdentityUser>>();
var signInManager = new Mock<SignInManager<IdentityUser>>(userManager.Object,
authManager.Object, claimsManager.Object);
signInManager.Setup(s => s.ValidateSecurityStamp(It.IsAny<ClaimsIdentity>(), 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<IdentityUser>(TimeSpan.FromDays(1)).Invoke(context);
Assert.NotNull(context.Identity);
}
}
}

View File

@ -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<ArgumentNullException>("identity", () => identity.GetUserId());
Assert.Throws<ArgumentNullException>("identity", () => identity.GetUserName());
ClaimsIdentity claimsIdentity = null;
Assert.Throws<ArgumentNullException>("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(); }
}
}
}
}

View File

@ -23,6 +23,7 @@
<ItemGroup>
<Compile Include="ClaimsIdentityFactoryTest.cs" />
<Compile Include="IdentityBuilderTest.cs" />
<Compile Include="ClaimsIdentityExtensionsTest.cs" />
<Compile Include="IdentityOptionsTest.cs" />
<Compile Include="IdentityResultTest.cs" />
<Compile Include="NoopRoleStore.cs" />