diff --git a/src/Microsoft.AspNetCore.Identity/IdentityOptions.cs b/src/Microsoft.AspNetCore.Identity/IdentityOptions.cs
index c942e56ff1..519a7ad44c 100644
--- a/src/Microsoft.AspNetCore.Identity/IdentityOptions.cs
+++ b/src/Microsoft.AspNetCore.Identity/IdentityOptions.cs
@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
namespace Microsoft.AspNetCore.Builder
@@ -74,5 +75,10 @@ namespace Microsoft.AspNetCore.Builder
/// The after which security stamps are re-validated.
///
public TimeSpan SecurityStampValidationInterval { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Invoked when the default security stamp validator replaces the user's ClaimsPrincipal in the cookie.
+ ///
+ public Func OnSecurityStampRefreshingPrincipal { get; set; }
}
}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Identity/SecurityStampRefreshingPrincipalContext.cs b/src/Microsoft.AspNetCore.Identity/SecurityStampRefreshingPrincipalContext.cs
new file mode 100644
index 0000000000..78b67463e3
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Identity/SecurityStampRefreshingPrincipalContext.cs
@@ -0,0 +1,23 @@
+// Copyright (c) .NET Foundation. 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 Microsoft.AspNetCore.Identity
+{
+ ///
+ /// Used to pass information during the SecurityStamp validation event.
+ ///
+ public class SecurityStampRefreshingPrincipalContext
+ {
+ ///
+ /// The principal contained in the current cookie.
+ ///
+ public ClaimsPrincipal CurrentPrincipal { get; set; }
+
+ ///
+ /// The new principal which should replace the current.
+ ///
+ public ClaimsPrincipal NewPrincipal { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Identity/SecurityStampValidator.cs b/src/Microsoft.AspNetCore.Identity/SecurityStampValidator.cs
index 490a1b68cf..7b4136e48f 100644
--- a/src/Microsoft.AspNetCore.Identity/SecurityStampValidator.cs
+++ b/src/Microsoft.AspNetCore.Identity/SecurityStampValidator.cs
@@ -66,8 +66,23 @@ namespace Microsoft.AspNetCore.Identity
var user = await _signInManager.ValidateSecurityStampAsync(context.Principal);
if (user != null)
{
- // REVIEW: note we lost login authenticaiton method
- context.ReplacePrincipal(await _signInManager.CreateUserPrincipalAsync(user));
+ var newPrincipal = await _signInManager.CreateUserPrincipalAsync(user);
+
+ if (_options.OnSecurityStampRefreshingPrincipal != null)
+ {
+ var replaceContext = new SecurityStampRefreshingPrincipalContext
+ {
+ CurrentPrincipal = context.Principal,
+ NewPrincipal = newPrincipal
+ };
+
+ // Note: a null principal is allowed and results in a failed authentication.
+ await _options.OnSecurityStampRefreshingPrincipal(replaceContext);
+ newPrincipal = replaceContext.NewPrincipal;
+ }
+
+ // REVIEW: note we lost login authentication method
+ context.ReplacePrincipal(newPrincipal);
context.ShouldRenew = true;
}
else
diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/FunctionalTest.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/FunctionalTest.cs
index 3653453092..273a3dd910 100644
--- a/test/Microsoft.AspNetCore.Identity.InMemory.Test/FunctionalTest.cs
+++ b/test/Microsoft.AspNetCore.Identity.InMemory.Test/FunctionalTest.cs
@@ -139,6 +139,54 @@ namespace Microsoft.AspNetCore.Identity.InMemory
Assert.Equal("hao", FindClaimValue(transaction6, ClaimTypes.Name));
}
+ [Fact]
+ public async Task CanAccessOldPrincipalDuringSecurityStampReplacement()
+ {
+ var clock = new TestClock();
+ var server = CreateServer(services => services.Configure(options =>
+ {
+ options.Cookies.ApplicationCookie.SystemClock = clock;
+ options.OnSecurityStampRefreshingPrincipal = c =>
+ {
+ var newId = new ClaimsIdentity();
+ newId.AddClaim(new Claim("PreviousName", c.CurrentPrincipal.Identity.Name));
+ c.NewPrincipal.AddIdentity(newId);
+ return Task.FromResult(0);
+ };
+ }));
+
+ var transaction1 = await SendAsync(server, "http://example.com/createMe");
+ Assert.Equal(HttpStatusCode.OK, transaction1.Response.StatusCode);
+ Assert.Null(transaction1.SetCookie);
+
+ var transaction2 = await SendAsync(server, "http://example.com/pwdLogin/false" );
+ Assert.Equal(HttpStatusCode.OK, transaction2.Response.StatusCode);
+ Assert.NotNull(transaction2.SetCookie);
+ Assert.DoesNotContain("; expires=", transaction2.SetCookie);
+
+ var transaction3 = await SendAsync(server, "http://example.com/me", transaction2.CookieNameValue);
+ Assert.Equal("hao", FindClaimValue(transaction3, ClaimTypes.Name));
+ Assert.Null(transaction3.SetCookie);
+
+ // Make sure we don't get a new cookie yet
+ clock.Add(TimeSpan.FromMinutes(10));
+ var transaction4 = await SendAsync(server, "http://example.com/me", transaction2.CookieNameValue);
+ Assert.Equal("hao", FindClaimValue(transaction4, ClaimTypes.Name));
+ Assert.Null(transaction4.SetCookie);
+
+ // Go past SecurityStampValidation interval and ensure we get a new cookie
+ clock.Add(TimeSpan.FromMinutes(21));
+
+ var transaction5 = await SendAsync(server, "http://example.com/me", transaction2.CookieNameValue);
+ Assert.NotNull(transaction5.SetCookie);
+ Assert.Equal("hao", FindClaimValue(transaction5, ClaimTypes.Name));
+ Assert.Equal("hao", FindClaimValue(transaction5, "PreviousName"));
+
+ // Make sure new cookie is valid
+ var transaction6 = await SendAsync(server, "http://example.com/me", transaction5.CookieNameValue);
+ Assert.Equal("hao", FindClaimValue(transaction6, ClaimTypes.Name));
+ }
+
[Fact]
public async Task TwoFactorRememberCookieVerification()
{