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() {