diff --git a/Security.sln b/Security.sln index c9951b0ddf..2cc23038e0 100644 --- a/Security.sln +++ b/Security.sln @@ -34,6 +34,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Security.M EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Security.OAuth", "src\Microsoft.AspNet.Security.OAuth\Microsoft.AspNet.Security.OAuth.kproj", "{4A636011-68EE-4CE5-836D-EA8E13CF71E4}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "CookieSessionSample", "samples\CookieSessionSample\CookieSessionSample.kproj", "{19711880-46DA-4A26-9E0F-9B2E41D27651}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -144,6 +146,16 @@ Global {4A636011-68EE-4CE5-836D-EA8E13CF71E4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {4A636011-68EE-4CE5-836D-EA8E13CF71E4}.Release|Mixed Platforms.Build.0 = Release|Any CPU {4A636011-68EE-4CE5-836D-EA8E13CF71E4}.Release|x86.ActiveCfg = Release|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Debug|x86.ActiveCfg = Debug|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Release|Any CPU.Build.0 = Release|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {19711880-46DA-4A26-9E0F-9B2E41D27651}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -159,5 +171,6 @@ Global {C96B77EA-4078-4C31-BDB2-878F11C5E061} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} {1FCF26C2-A3C7-4308-B698-4AFC3560BC0C} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} {4A636011-68EE-4CE5-836D-EA8E13CF71E4} = {4D2B6A51-2F9F-44F5-8131-EA5CAC053652} + {19711880-46DA-4A26-9E0F-9B2E41D27651} = {F8C0AA27-F3FB-4286-8E4C-47EF86B539FF} EndGlobalSection EndGlobal diff --git a/samples/CookieSample/project.json b/samples/CookieSample/project.json index c8eabcb68c..405e3ba0d0 100644 --- a/samples/CookieSample/project.json +++ b/samples/CookieSample/project.json @@ -6,8 +6,8 @@ "Microsoft.AspNet.HttpFeature": "1.0.0-*", "Microsoft.AspNet.PipelineCore": "1.0.0-*", "Microsoft.AspNet.RequestContainer": "1.0.0-*", - "Microsoft.AspNet.Security": "", - "Microsoft.AspNet.Security.Cookies": "", + "Microsoft.AspNet.Security": "1.0.0-*", + "Microsoft.AspNet.Security.Cookies": "1.0.0-*", "Microsoft.AspNet.Server.WebListener": "1.0.0-*", "Microsoft.Framework.DependencyInjection": "1.0.0-*" }, diff --git a/samples/CookieSessionSample/CookieSessionSample.kproj b/samples/CookieSessionSample/CookieSessionSample.kproj new file mode 100644 index 0000000000..951ff1cc56 --- /dev/null +++ b/samples/CookieSessionSample/CookieSessionSample.kproj @@ -0,0 +1,32 @@ + + + + 12.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + Debug + AnyCPU + + + + + 19711880-46da-4a26-9e0f-9b2e41d27651 + Library + CookieSessionSample + + + + ConsoleDebugger + + + WebDebugger + + + + + + 2.0 + + + \ No newline at end of file diff --git a/samples/CookieSessionSample/MemoryCacheSessionStore.cs b/samples/CookieSessionSample/MemoryCacheSessionStore.cs new file mode 100644 index 0000000000..07adddd048 --- /dev/null +++ b/samples/CookieSessionSample/MemoryCacheSessionStore.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNet.MemoryCache; +using Microsoft.AspNet.Security; +using Microsoft.AspNet.Security.Cookies.Infrastructure; + +namespace CookieSessionSample +{ + public class MemoryCacheSessionStore : IAuthenticationSessionStore + { + private const string KeyPrefix = "AuthSessionStore-"; + private IMemoryCache _cache; + + public MemoryCacheSessionStore() + { + _cache = new MemoryCache(); + } + + public async Task StoreAsync(AuthenticationTicket ticket) + { + var guid = Guid.NewGuid(); + var key = KeyPrefix + guid.ToString(); + await RenewAsync(key, ticket); + return key; + } + + public Task RenewAsync(string key, AuthenticationTicket ticket) + { + _cache.Set(key, ticket, context => + { + var expiresUtc = ticket.Properties.ExpiresUtc; + if (expiresUtc.HasValue) + { + context.SetAbsoluteExpiration(expiresUtc.Value); + } + context.SetSlidingExpiraiton(TimeSpan.FromHours(1)); // TODO: configurable. + + return (AuthenticationTicket)context.State; + }); + return Task.FromResult(0); + } + + public Task RetrieveAsync(string key) + { + AuthenticationTicket ticket; + _cache.TryGetValue(key, out ticket); + return Task.FromResult(ticket); + } + + public Task RemoveAsync(string key) + { + _cache.Remove(key); + return Task.FromResult(0); + } + } +} diff --git a/samples/CookieSessionSample/Startup.cs b/samples/CookieSessionSample/Startup.cs new file mode 100644 index 0000000000..c5359a05f3 --- /dev/null +++ b/samples/CookieSessionSample/Startup.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Security.Claims; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Security.Cookies; + +namespace CookieSessionSample +{ + public class Startup + { + public void Configure(IBuilder app) + { + app.UseCookieAuthentication(new CookieAuthenticationOptions() + { + SessionStore = new MemoryCacheSessionStore(), + }); + + app.Run(async context => + { + if (context.User == null || !context.User.Identity.IsAuthenticated) + { + // Make a large identity + var claims = new List(1001); + claims.Add(new Claim("name", "bob")); + for (int i = 0; i < 1000; i++) + { + claims.Add(new Claim(ClaimTypes.Role, "SomeRandomGroup" + i, ClaimValueTypes.String, "IssuedByBob", "OriginalIssuerJoe")); + } + context.Response.SignIn(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationType)); + + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Hello First timer"); + return; + } + + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Hello old timer"); + }); + } + } +} \ No newline at end of file diff --git a/samples/CookieSessionSample/project.json b/samples/CookieSessionSample/project.json new file mode 100644 index 0000000000..7299177120 --- /dev/null +++ b/samples/CookieSessionSample/project.json @@ -0,0 +1,37 @@ +{ + "dependencies": { + "Microsoft.AspNet.FeatureModel": "1.0.0-*", + "Microsoft.AspNet.Hosting": "1.0.0-*", + "Microsoft.AspNet.Http": "1.0.0-*", + "Microsoft.AspNet.HttpFeature": "1.0.0-*", + "Microsoft.AspNet.MemoryCache": "1.0.0-*", + "Microsoft.AspNet.PipelineCore": "1.0.0-*", + "Microsoft.AspNet.RequestContainer": "1.0.0-*", + "Microsoft.AspNet.Security": "1.0.0-*", + "Microsoft.AspNet.Security.Cookies": "1.0.0-*", + "Microsoft.AspNet.Server.WebListener": "1.0.0-*", + "Microsoft.Framework.DependencyInjection": "1.0.0-*" + }, + "commands": { "web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:12345" }, + "frameworks": { + "aspnet50": { + }, + "aspnetcore50": { + "dependencies": { + "System.Collections": "4.0.10.0", + "System.Console": "4.0.0.0", + "System.Diagnostics.Debug": "4.0.10.0", + "System.Diagnostics.Tools": "4.0.0.0", + "System.Globalization": "4.0.10.0", + "System.IO": "4.0.10.0", + "System.Linq": "4.0.0.0", + "System.Reflection": "4.0.10.0", + "System.Resources.ResourceManager": "4.0.0.0", + "System.Runtime": "4.0.20.0", + "System.Runtime.Extensions": "4.0.10.0", + "System.Runtime.InteropServices": "4.0.20.0", + "System.Threading.Tasks": "4.0.10.0" + } + } + } +} diff --git a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs index 23f66b6c5b..317c27d21a 100644 --- a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs +++ b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs @@ -3,6 +3,8 @@ using System; +using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.Http.Security; @@ -18,12 +20,14 @@ namespace Microsoft.AspNet.Security.Cookies private const string HeaderNameExpires = "Expires"; private const string HeaderValueNoCache = "no-cache"; private const string HeaderValueMinusOne = "-1"; + private const string SessionIdClaim = "Microsoft.AspNet.Security.Cookies-SessionId"; private readonly ILogger _logger; private bool _shouldRenew; private DateTimeOffset _renewIssuedUtc; private DateTimeOffset _renewExpiresUtc; + private string _sessionKey; public CookieAuthenticationHandler([NotNull] ILogger logger) { @@ -51,12 +55,33 @@ namespace Microsoft.AspNet.Security.Cookies return null; } + if (Options.SessionStore != null) + { + Claim claim = ticket.Identity.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaim)); + if (claim == null) + { + _logger.WriteWarning(@"SessoinId missing"); + return null; + } + _sessionKey = claim.Value; + ticket = await Options.SessionStore.RetrieveAsync(_sessionKey); + if (ticket == null) + { + _logger.WriteWarning(@"Identity missing in session store"); + return null; + } + } + DateTimeOffset currentUtc = Options.SystemClock.UtcNow; DateTimeOffset? issuedUtc = ticket.Properties.IssuedUtc; DateTimeOffset? expiresUtc = ticket.Properties.ExpiresUtc; if (expiresUtc != null && expiresUtc.Value < currentUtc) { + if (Options.SessionStore != null) + { + await Options.SessionStore.RemoveAsync(_sessionKey); + } return null; } @@ -95,6 +120,7 @@ namespace Microsoft.AspNet.Security.Cookies if (shouldSignin || shouldSignout || _shouldRenew) { + AuthenticationTicket model = await AuthenticateAsync(); var cookieOptions = new CookieOptions { Domain = Options.CookieDomain, @@ -144,7 +170,19 @@ namespace Microsoft.AspNet.Security.Cookies context.CookieOptions.Expires = expiresUtc.ToUniversalTime().DateTime; } - var model = new AuthenticationTicket(context.Identity, context.Properties); + model = new AuthenticationTicket(context.Identity, context.Properties); + if (Options.SessionStore != null) + { + if (_sessionKey != null) + { + await Options.SessionStore.RemoveAsync(_sessionKey); + } + _sessionKey = await Options.SessionStore.StoreAsync(model); + ClaimsIdentity identity = new ClaimsIdentity( + new[] { new Claim(SessionIdClaim, _sessionKey) }, + Options.AuthenticationType); + model = new AuthenticationTicket(identity, null); + } string cookieValue = Options.TicketDataFormat.Protect(model); Options.CookieManager.AppendResponseCookie( @@ -155,6 +193,11 @@ namespace Microsoft.AspNet.Security.Cookies } else if (shouldSignout) { + if (Options.SessionStore != null && _sessionKey != null) + { + await Options.SessionStore.RemoveAsync(_sessionKey); + } + var context = new CookieResponseSignOutContext( Context, Options, @@ -169,11 +212,18 @@ namespace Microsoft.AspNet.Security.Cookies } else if (_shouldRenew) { - AuthenticationTicket model = await AuthenticateAsync(); - model.Properties.IssuedUtc = _renewIssuedUtc; model.Properties.ExpiresUtc = _renewExpiresUtc; + if (Options.SessionStore != null && _sessionKey != null) + { + await Options.SessionStore.RenewAsync(_sessionKey, model); + ClaimsIdentity identity = new ClaimsIdentity( + new[] { new Claim(SessionIdClaim, _sessionKey) }, + Options.AuthenticationType); + model = new AuthenticationTicket(identity, null); + } + string cookieValue = Options.TicketDataFormat.Protect(model); if (model.Properties.IsPersistent) diff --git a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs index 0b6ef879e9..c6373a0ec9 100644 --- a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs +++ b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs @@ -143,5 +143,11 @@ namespace Microsoft.AspNet.Security.Cookies /// ChunkingCookieManager will be used by default. /// public ICookieManager CookieManager { get; set; } + + /// + /// An optional container in which to store the identity across requests. When used, only a session identifier is sent + /// to the client. This can be used to mitigate potential problems with very large identities. + /// + public IAuthenticationSessionStore SessionStore { get; set; } } } diff --git a/src/Microsoft.AspNet.Security.Cookies/Infrastructure/IAuthenticationSessionStore.cs b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/IAuthenticationSessionStore.cs new file mode 100644 index 0000000000..9f449b098b --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/IAuthenticationSessionStore.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.AspNet.Security.Cookies.Infrastructure +{ + /// + /// This provides an abstract storage mechanic to preserve identity information on the server + /// while only sending a simple identifier key to the client. This is most commonly used to mitigate + /// issues with serializing large identities into cookies. + /// + public interface IAuthenticationSessionStore + { + /// + /// Store the identity ticket and return the associated key. + /// + /// The identity information to store. + /// The key that can be used to retrieve the identity later. + Task StoreAsync(AuthenticationTicket ticket); + + /// + /// Tells the store that the given identity should be updated. + /// + /// + /// + /// + Task RenewAsync(string key, AuthenticationTicket ticket); + + /// + /// Retrieves an identity from the store for the given key. + /// + /// The key associated with the identity. + /// The identity associated with the given key, or if not found. + Task RetrieveAsync(string key); + + /// + /// Remove the identity associated with the given key. + /// + /// The key associated with the identity. + /// + Task RemoveAsync(string key); + } +}