diff --git a/src/Session/.gitignore b/src/Session/.gitignore new file mode 100644 index 0000000000..f332e76e0f --- /dev/null +++ b/src/Session/.gitignore @@ -0,0 +1,33 @@ +[Oo]bj/ +[Bb]in/ +TestResults/ +.nuget/ +*.sln.ide/ +_ReSharper.*/ +packages/ +artifacts/ +PublishProfiles/ +*.user +*.suo +*.cache +*.docstates +_ReSharper.* +nuget.exe +*net45.csproj +*net451.csproj +*k10.csproj +*.psess +*.vsp +*.pidb +*.userprefs +*DS_Store +*.ncrunchsolution +*.*sdf +*.ipch +.vs/ +.vscode/ +project.lock.json +.build/ +.testPublish/ +launchSettings.json +global.json diff --git a/src/Session/Directory.Build.props b/src/Session/Directory.Build.props new file mode 100644 index 0000000000..c2790fd6a6 --- /dev/null +++ b/src/Session/Directory.Build.props @@ -0,0 +1,21 @@ + + + + + + + + + Microsoft ASP.NET Core + https://github.com/aspnet/Session + git + $(MSBuildThisFileDirectory) + $(MSBuildThisFileDirectory)build\Key.snk + true + true + true + + + diff --git a/src/Session/Directory.Build.targets b/src/Session/Directory.Build.targets new file mode 100644 index 0000000000..53b3f6e1da --- /dev/null +++ b/src/Session/Directory.Build.targets @@ -0,0 +1,7 @@ + + + $(MicrosoftNETCoreApp20PackageVersion) + $(MicrosoftNETCoreApp21PackageVersion) + $(NETStandardLibrary20PackageVersion) + + diff --git a/src/Session/NuGetPackageVerifier.json b/src/Session/NuGetPackageVerifier.json new file mode 100644 index 0000000000..b153ab1515 --- /dev/null +++ b/src/Session/NuGetPackageVerifier.json @@ -0,0 +1,7 @@ +{ + "Default": { + "rules": [ + "DefaultCompositeRule" + ] + } +} \ No newline at end of file diff --git a/src/Session/README.md b/src/Session/README.md new file mode 100644 index 0000000000..976f9f21cf --- /dev/null +++ b/src/Session/README.md @@ -0,0 +1,12 @@ +Session +================ + +AppVeyor: [![AppVeyor](https://ci.appveyor.com/api/projects/status/yyivj6uwu3uj2x40/branch/dev?svg=true)](https://ci.appveyor.com/project/aspnetci/Session/branch/dev) + +Travis: [![Travis](https://travis-ci.org/aspnet/Session.svg?branch=dev)](https://travis-ci.org/aspnet/Session) + +Contains libraries for session state middleware for ASP.NET Core. + +For ASP.NET 4.x session state, please go to https://github.com/aspnet/AspNetSessionState. + +This project is part of ASP.NET Core. You can find samples, documentation and getting started instructions for ASP.NET Core at the [Home](https://github.com/aspnet/home) repo. diff --git a/src/Session/Session.sln b/src/Session/Session.sln new file mode 100644 index 0000000000..d8d95dc295 --- /dev/null +++ b/src/Session/Session.sln @@ -0,0 +1,74 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26621.2 +MinimumVisualStudioVersion = 15.0.26730.03 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{E9D63F97-6078-42AD-BFD3-F956BF921BB5}" + ProjectSection(SolutionItems) = preProject + test\Directory.Build.props = test\Directory.Build.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A189F10C-3A9C-4F81-83D0-32E5FE50DAD8}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Session", "src\Microsoft.AspNetCore.Session\Microsoft.AspNetCore.Session.csproj", "{71802736-F640-4733-9671-02D267EDD76A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Session.Tests", "test\Microsoft.AspNetCore.Session.Tests\Microsoft.AspNetCore.Session.Tests.csproj", "{8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{94E80ED2-9F27-40AC-A9EF-C707BDFAA3BE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SessionSample", "samples\SessionSample\SessionSample.csproj", "{FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{3B45F658-5BF1-4E07-BE9C-6F5110AC2277}" + ProjectSection(SolutionItems) = preProject + .appveyor.yml = .appveyor.yml + .gitattributes = .gitattributes + .gitignore = .gitignore + .travis.yml = .travis.yml + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + NuGet.config = NuGet.config + NuGetPackageVerifier.json = NuGetPackageVerifier.json + README.md = README.md + version.xml = version.xml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{4F21221F-2813-41B7-AAFC-E03FD52971CC}" + ProjectSection(SolutionItems) = preProject + build\common.props = build\common.props + build\dependencies.props = build\dependencies.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71802736-F640-4733-9671-02D267EDD76A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71802736-F640-4733-9671-02D267EDD76A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71802736-F640-4733-9671-02D267EDD76A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71802736-F640-4733-9671-02D267EDD76A}.Release|Any CPU.Build.0 = Release|Any CPU + {8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}.Release|Any CPU.Build.0 = Release|Any CPU + {FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {71802736-F640-4733-9671-02D267EDD76A} = {A189F10C-3A9C-4F81-83D0-32E5FE50DAD8} + {8C131A0A-BC1A-4CF3-8B77-8813FBFE5639} = {E9D63F97-6078-42AD-BFD3-F956BF921BB5} + {FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365} = {94E80ED2-9F27-40AC-A9EF-C707BDFAA3BE} + {4F21221F-2813-41B7-AAFC-E03FD52971CC} = {3B45F658-5BF1-4E07-BE9C-6F5110AC2277} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6AE224B9-B604-4E47-9617-9D114DAE9BE5} + EndGlobalSection +EndGlobal diff --git a/src/Session/build/Key.snk b/src/Session/build/Key.snk new file mode 100644 index 0000000000..e10e4889c1 Binary files /dev/null and b/src/Session/build/Key.snk differ diff --git a/src/Session/build/dependencies.props b/src/Session/build/dependencies.props new file mode 100644 index 0000000000..6e86d21f90 --- /dev/null +++ b/src/Session/build/dependencies.props @@ -0,0 +1,36 @@ + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + + + + + 2.1.3-rtm-15802 + 2.0.0 + 2.1.2 + 15.6.1 + 2.0.3 + 2.3.1 + 2.4.0-beta.1.build3945 + + + + + + + + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.2 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + 2.1.1 + + \ No newline at end of file diff --git a/src/Session/build/repo.props b/src/Session/build/repo.props new file mode 100644 index 0000000000..dab1601c88 --- /dev/null +++ b/src/Session/build/repo.props @@ -0,0 +1,15 @@ + + + + + + Internal.AspNetCore.Universe.Lineup + 2.1.0-rc1-* + https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json + + + + + + + diff --git a/src/Session/build/sources.props b/src/Session/build/sources.props new file mode 100644 index 0000000000..9215df9751 --- /dev/null +++ b/src/Session/build/sources.props @@ -0,0 +1,17 @@ + + + + + $(DotNetRestoreSources) + + $(RestoreSources); + https://dotnet.myget.org/F/dotnet-core/api/v3/index.json; + https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json; + https://dotnet.myget.org/F/aspnetcore-tools/api/v3/index.json; + + + $(RestoreSources); + https://api.nuget.org/v3/index.json; + + + diff --git a/src/Session/samples/SessionSample/Properties/launchSettings.json b/src/Session/samples/SessionSample/Properties/launchSettings.json new file mode 100644 index 0000000000..6bab3d3602 --- /dev/null +++ b/src/Session/samples/SessionSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:2481/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "SessionSample": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/Session/samples/SessionSample/SessionSample.csproj b/src/Session/samples/SessionSample/SessionSample.csproj new file mode 100644 index 0000000000..67abefae09 --- /dev/null +++ b/src/Session/samples/SessionSample/SessionSample.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.1;net461 + + + + + + + + + + + + + + + + diff --git a/src/Session/samples/SessionSample/Startup.cs b/src/Session/samples/SessionSample/Startup.cs new file mode 100644 index 0000000000..41fcb566c5 --- /dev/null +++ b/src/Session/samples/SessionSample/Startup.cs @@ -0,0 +1,90 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SessionSample +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + // Adds a default in-memory implementation of IDistributedCache + services.AddDistributedMemoryCache(); + + // Uncomment the following line to use the Microsoft SQL Server implementation of IDistributedCache. + // Note that this would require setting up the session state database. + //services.AddSqlServerCache(o => + //{ + // o.ConnectionString = "Server=.;Database=ASPNET5SessionState;Trusted_Connection=True;"; + // o.SchemaName = "dbo"; + // o.TableName = "Sessions"; + //}); + + // Uncomment the following line to use the Redis implementation of IDistributedCache. + // This will override any previously registered IDistributedCache service. + //services.AddDistributedRedisCache(o => + //{ + // o.Configuration = "localhost"; + // o.InstanceName = "SampleInstance"; + //}); + + services.AddSession(o => + { + o.IdleTimeout = TimeSpan.FromSeconds(10); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseSession(); + + app.Map("/session", subApp => + { + subApp.Run(async context => + { + int visits = 0; + visits = context.Session.GetInt32("visits") ?? 0; + context.Session.SetInt32("visits", ++visits); + await context.Response.WriteAsync("Counting: You have visited our page this many times: " + visits); + }); + }); + + app.Run(async context => + { + int visits = 0; + visits = context.Session.GetInt32("visits") ?? 0; + await context.Response.WriteAsync(""); + if (visits == 0) + { + await context.Response.WriteAsync("Your session has not been established.
"); + await context.Response.WriteAsync(DateTime.Now + "
"); + await context.Response.WriteAsync("Establish session.
"); + } + else + { + context.Session.SetInt32("visits", ++visits); + await context.Response.WriteAsync("Your session was located, you've visited the site this many times: " + visits); + } + await context.Response.WriteAsync(""); + }); + } + + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .ConfigureLogging(factory => factory.AddConsole()) + .UseKestrel() + .UseIISIntegration() + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/src/Session/src/Directory.Build.props b/src/Session/src/Directory.Build.props new file mode 100644 index 0000000000..1e0980f663 --- /dev/null +++ b/src/Session/src/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Session/src/Microsoft.AspNetCore.Session/CookieProtection.cs b/src/Session/src/Microsoft.AspNetCore.Session/CookieProtection.cs new file mode 100644 index 0000000000..64a3a3fbbf --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/CookieProtection.cs @@ -0,0 +1,71 @@ +// 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; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Session +{ + internal static class CookieProtection + { + internal static string Protect(IDataProtector protector, string data) + { + if (protector == null) + { + throw new ArgumentNullException(nameof(protector)); + } + if (string.IsNullOrEmpty(data)) + { + return data; + } + + var userData = Encoding.UTF8.GetBytes(data); + + var protectedData = protector.Protect(userData); + return Convert.ToBase64String(protectedData).TrimEnd('='); + } + + internal static string Unprotect(IDataProtector protector, string protectedText, ILogger logger) + { + try + { + if (string.IsNullOrEmpty(protectedText)) + { + return string.Empty; + } + + var protectedData = Convert.FromBase64String(Pad(protectedText)); + if (protectedData == null) + { + return string.Empty; + } + + var userData = protector.Unprotect(protectedData); + if (userData == null) + { + return string.Empty; + } + + return Encoding.UTF8.GetString(userData); + } + catch (Exception ex) + { + // Log the exception, but do not leak other information + logger.ErrorUnprotectingSessionCookie(ex); + return string.Empty; + } + } + + private static string Pad(string text) + { + var padding = 3 - ((text.Length + 3) % 4); + if (padding == 0) + { + return text; + } + return text + new string('=', padding); + } + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/DistributedSession.cs b/src/Session/src/Microsoft.AspNetCore.Session/DistributedSession.cs new file mode 100644 index 0000000000..76ab3f3cf5 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/DistributedSession.cs @@ -0,0 +1,425 @@ +// 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Session +{ + public class DistributedSession : ISession + { + private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create(); + private const int IdByteCount = 16; + + private const byte SerializationRevision = 2; + private const int KeyLengthLimit = ushort.MaxValue; + + private readonly IDistributedCache _cache; + private readonly string _sessionKey; + private readonly TimeSpan _idleTimeout; + private readonly TimeSpan _ioTimeout; + private readonly Func _tryEstablishSession; + private readonly ILogger _logger; + private IDictionary _store; + private bool _isModified; + private bool _loaded; + private bool _isAvailable; + private bool _isNewSessionKey; + private string _sessionId; + private byte[] _sessionIdBytes; + + public DistributedSession( + IDistributedCache cache, + string sessionKey, + TimeSpan idleTimeout, + TimeSpan ioTimeout, + Func tryEstablishSession, + ILoggerFactory loggerFactory, + bool isNewSessionKey) + { + if (cache == null) + { + throw new ArgumentNullException(nameof(cache)); + } + + if (string.IsNullOrEmpty(sessionKey)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(sessionKey)); + } + + if (tryEstablishSession == null) + { + throw new ArgumentNullException(nameof(tryEstablishSession)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _cache = cache; + _sessionKey = sessionKey; + _idleTimeout = idleTimeout; + _ioTimeout = ioTimeout; + _tryEstablishSession = tryEstablishSession; + _store = new Dictionary(); + _logger = loggerFactory.CreateLogger(); + _isNewSessionKey = isNewSessionKey; + } + + public bool IsAvailable + { + get + { + Load(); + return _isAvailable; + } + } + + public string Id + { + get + { + Load(); + if (_sessionId == null) + { + _sessionId = new Guid(IdBytes).ToString(); + } + return _sessionId; + } + } + + private byte[] IdBytes + { + get + { + if (IsAvailable && _sessionIdBytes == null) + { + _sessionIdBytes = new byte[IdByteCount]; + CryptoRandom.GetBytes(_sessionIdBytes); + } + return _sessionIdBytes; + } + } + + public IEnumerable Keys + { + get + { + Load(); + return _store.Keys.Select(key => key.KeyString); + } + } + + public bool TryGetValue(string key, out byte[] value) + { + Load(); + return _store.TryGetValue(new EncodedKey(key), out value); + } + + public void Set(string key, byte[] value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (IsAvailable) + { + var encodedKey = new EncodedKey(key); + if (encodedKey.KeyBytes.Length > KeyLengthLimit) + { + throw new ArgumentOutOfRangeException(nameof(key), + Resources.FormatException_KeyLengthIsExceeded(KeyLengthLimit)); + } + + if (!_tryEstablishSession()) + { + throw new InvalidOperationException(Resources.Exception_InvalidSessionEstablishment); + } + _isModified = true; + byte[] copy = new byte[value.Length]; + Buffer.BlockCopy(src: value, srcOffset: 0, dst: copy, dstOffset: 0, count: value.Length); + _store[encodedKey] = copy; + } + } + + public void Remove(string key) + { + Load(); + _isModified |= _store.Remove(new EncodedKey(key)); + } + + public void Clear() + { + Load(); + _isModified |= _store.Count > 0; + _store.Clear(); + } + + private void Load() + { + if (!_loaded) + { + try + { + var data = _cache.Get(_sessionKey); + if (data != null) + { + Deserialize(new MemoryStream(data)); + } + else if (!_isNewSessionKey) + { + _logger.AccessingExpiredSession(_sessionKey); + } + _isAvailable = true; + } + catch (Exception exception) + { + _logger.SessionCacheReadException(_sessionKey, exception); + _isAvailable = false; + _sessionId = string.Empty; + _sessionIdBytes = null; + _store = new NoOpSessionStore(); + } + finally + { + _loaded = true; + } + } + } + + // This will throw if called directly and a failure occurs. The user is expected to handle the failures. + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!_loaded) + { + using (var timeout = new CancellationTokenSource(_ioTimeout)) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken); + try + { + cts.Token.ThrowIfCancellationRequested(); + var data = await _cache.GetAsync(_sessionKey, cts.Token); + if (data != null) + { + Deserialize(new MemoryStream(data)); + } + else if (!_isNewSessionKey) + { + _logger.AccessingExpiredSession(_sessionKey); + } + } + catch (OperationCanceledException oex) + { + if (timeout.Token.IsCancellationRequested) + { + _logger.SessionLoadingTimeout(); + throw new OperationCanceledException("Timed out loading the session.", oex, timeout.Token); + } + throw; + } + } + _isAvailable = true; + _loaded = true; + } + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + using (var timeout = new CancellationTokenSource(_ioTimeout)) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken); + if (_isModified) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + // This operation is only so we can log if the session already existed. + // Log and ignore failures. + try + { + cts.Token.ThrowIfCancellationRequested(); + var data = await _cache.GetAsync(_sessionKey, cts.Token); + if (data == null) + { + _logger.SessionStarted(_sessionKey, Id); + } + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + _logger.SessionCacheReadException(_sessionKey, exception); + } + } + + var stream = new MemoryStream(); + Serialize(stream); + + try + { + cts.Token.ThrowIfCancellationRequested(); + await _cache.SetAsync( + _sessionKey, + stream.ToArray(), + new DistributedCacheEntryOptions().SetSlidingExpiration(_idleTimeout), + cts.Token); + _isModified = false; + _logger.SessionStored(_sessionKey, Id, _store.Count); + } + catch (OperationCanceledException oex) + { + if (timeout.Token.IsCancellationRequested) + { + _logger.SessionCommitTimeout(); + throw new OperationCanceledException("Timed out committing the session.", oex, timeout.Token); + } + throw; + } + } + else + { + try + { + await _cache.RefreshAsync(_sessionKey, cts.Token); + } + catch (OperationCanceledException oex) + { + if (timeout.Token.IsCancellationRequested) + { + _logger.SessionRefreshTimeout(); + throw new OperationCanceledException("Timed out refreshing the session.", oex, timeout.Token); + } + throw; + } + } + } + } + + // Format: + // Serialization revision: 1 byte, range 0-255 + // Entry count: 3 bytes, range 0-16,777,215 + // SessionId: IdByteCount bytes (16) + // foreach entry: + // key name byte length: 2 bytes, range 0-65,535 + // UTF-8 encoded key name byte[] + // data byte length: 4 bytes, range 0-2,147,483,647 + // data byte[] + private void Serialize(Stream output) + { + output.WriteByte(SerializationRevision); + SerializeNumAs3Bytes(output, _store.Count); + output.Write(IdBytes, 0, IdByteCount); + + foreach (var entry in _store) + { + var keyBytes = entry.Key.KeyBytes; + SerializeNumAs2Bytes(output, keyBytes.Length); + output.Write(keyBytes, 0, keyBytes.Length); + SerializeNumAs4Bytes(output, entry.Value.Length); + output.Write(entry.Value, 0, entry.Value.Length); + } + } + + private void Deserialize(Stream content) + { + if (content == null || content.ReadByte() != SerializationRevision) + { + // Replace the un-readable format. + _isModified = true; + return; + } + + int expectedEntries = DeserializeNumFrom3Bytes(content); + _sessionIdBytes = ReadBytes(content, IdByteCount); + + for (int i = 0; i < expectedEntries; i++) + { + int keyLength = DeserializeNumFrom2Bytes(content); + var key = new EncodedKey(ReadBytes(content, keyLength)); + int dataLength = DeserializeNumFrom4Bytes(content); + _store[key] = ReadBytes(content, dataLength); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _sessionId = new Guid(_sessionIdBytes).ToString(); + _logger.SessionLoaded(_sessionKey, _sessionId, expectedEntries); + } + } + + private void SerializeNumAs2Bytes(Stream output, int num) + { + if (num < 0 || ushort.MaxValue < num) + { + throw new ArgumentOutOfRangeException(nameof(num), Resources.Exception_InvalidToSerializeIn2Bytes); + } + output.WriteByte((byte)(num >> 8)); + output.WriteByte((byte)(0xFF & num)); + } + + private int DeserializeNumFrom2Bytes(Stream content) + { + return content.ReadByte() << 8 | content.ReadByte(); + } + + private void SerializeNumAs3Bytes(Stream output, int num) + { + if (num < 0 || 0xFFFFFF < num) + { + throw new ArgumentOutOfRangeException(nameof(num), Resources.Exception_InvalidToSerializeIn3Bytes); + } + output.WriteByte((byte)(num >> 16)); + output.WriteByte((byte)(0xFF & (num >> 8))); + output.WriteByte((byte)(0xFF & num)); + } + + private int DeserializeNumFrom3Bytes(Stream content) + { + return content.ReadByte() << 16 | content.ReadByte() << 8 | content.ReadByte(); + } + + private void SerializeNumAs4Bytes(Stream output, int num) + { + if (num < 0) + { + throw new ArgumentOutOfRangeException(nameof(num), Resources.Exception_NumberShouldNotBeNegative); + } + output.WriteByte((byte)(num >> 24)); + output.WriteByte((byte)(0xFF & (num >> 16))); + output.WriteByte((byte)(0xFF & (num >> 8))); + output.WriteByte((byte)(0xFF & num)); + } + + private int DeserializeNumFrom4Bytes(Stream content) + { + return content.ReadByte() << 24 | content.ReadByte() << 16 | content.ReadByte() << 8 | content.ReadByte(); + } + + private byte[] ReadBytes(Stream stream, int count) + { + var output = new byte[count]; + int total = 0; + while (total < count) + { + var read = stream.Read(output, total, count - total); + if (read == 0) + { + throw new EndOfStreamException(); + } + total += read; + } + return output; + } + + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/DistributedSessionStore.cs b/src/Session/src/Microsoft.AspNetCore.Session/DistributedSessionStore.cs new file mode 100644 index 0000000000..49050af588 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/DistributedSessionStore.cs @@ -0,0 +1,47 @@ +// 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; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Session +{ + public class DistributedSessionStore : ISessionStore + { + private readonly IDistributedCache _cache; + private readonly ILoggerFactory _loggerFactory; + + public DistributedSessionStore(IDistributedCache cache, ILoggerFactory loggerFactory) + { + if (cache == null) + { + throw new ArgumentNullException(nameof(cache)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _cache = cache; + _loggerFactory = loggerFactory; + } + + public ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func tryEstablishSession, bool isNewSessionKey) + { + if (string.IsNullOrEmpty(sessionKey)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(sessionKey)); + } + + if (tryEstablishSession == null) + { + throw new ArgumentNullException(nameof(tryEstablishSession)); + } + + return new DistributedSession(_cache, sessionKey, idleTimeout, ioTimeout, tryEstablishSession, _loggerFactory, isNewSessionKey); + } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/EncodedKey.cs b/src/Session/src/Microsoft.AspNetCore.Session/EncodedKey.cs new file mode 100644 index 0000000000..ac169542f5 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/EncodedKey.cs @@ -0,0 +1,80 @@ +// 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.Text; + +namespace Microsoft.AspNetCore.Session +{ + // Keys are stored in their utf-8 encoded state. + // This saves us from de-serializing and re-serializing every key on every request. + internal class EncodedKey + { + private string _keyString; + private int? _hashCode; + + internal EncodedKey(string key) + { + _keyString = key; + KeyBytes = Encoding.UTF8.GetBytes(key); + } + + public EncodedKey(byte[] key) + { + KeyBytes = key; + } + + internal string KeyString + { + get + { + if (_keyString == null) + { + _keyString = Encoding.UTF8.GetString(KeyBytes, 0, KeyBytes.Length); + } + return _keyString; + } + } + + internal byte[] KeyBytes { get; private set; } + + public override bool Equals(object obj) + { + var otherKey = obj as EncodedKey; + if (otherKey == null) + { + return false; + } + if (KeyBytes.Length != otherKey.KeyBytes.Length) + { + return false; + } + if (_hashCode.HasValue && otherKey._hashCode.HasValue + && _hashCode.Value != otherKey._hashCode.Value) + { + return false; + } + for (int i = 0; i < KeyBytes.Length; i++) + { + if (KeyBytes[i] != otherKey.KeyBytes[i]) + { + return false; + } + } + return true; + } + + public override int GetHashCode() + { + if (!_hashCode.HasValue) + { + _hashCode = SipHash.GetHashCode(KeyBytes); + } + return _hashCode.Value; + } + + public override string ToString() + { + return KeyString; + } + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/ISessionStore.cs b/src/Session/src/Microsoft.AspNetCore.Session/ISessionStore.cs new file mode 100644 index 0000000000..247ba2307f --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/ISessionStore.cs @@ -0,0 +1,13 @@ +// 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; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Session +{ + public interface ISessionStore + { + ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func tryEstablishSession, bool isNewSessionKey); + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/LoggingExtensions.cs b/src/Session/src/Microsoft.AspNetCore.Session/LoggingExtensions.cs new file mode 100644 index 0000000000..2552ac20de --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/LoggingExtensions.cs @@ -0,0 +1,135 @@ +// 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; + +namespace Microsoft.Extensions.Logging +{ + internal static class LoggingExtensions + { + private static Action _errorClosingTheSession; + private static Action _accessingExpiredSession; + private static Action _sessionStarted; + private static Action _sessionLoaded; + private static Action _sessionStored; + private static Action _sessionCacheReadException; + private static Action _errorUnprotectingCookie; + private static Action _sessionLoadingTimeout; + private static Action _sessionCommitTimeout; + private static Action _sessionCommitCanceled; + private static Action _sessionRefreshTimeout; + private static Action _sessionRefreshCanceled; + + static LoggingExtensions() + { + _errorClosingTheSession = LoggerMessage.Define( + eventId: 1, + logLevel: LogLevel.Error, + formatString: "Error closing the session."); + _accessingExpiredSession = LoggerMessage.Define( + eventId: 2, + logLevel: LogLevel.Information, + formatString: "Accessing expired session, Key:{sessionKey}"); + _sessionStarted = LoggerMessage.Define( + eventId: 3, + logLevel: LogLevel.Information, + formatString: "Session started; Key:{sessionKey}, Id:{sessionId}"); + _sessionLoaded = LoggerMessage.Define( + eventId: 4, + logLevel: LogLevel.Debug, + formatString: "Session loaded; Key:{sessionKey}, Id:{sessionId}, Count:{count}"); + _sessionStored = LoggerMessage.Define( + eventId: 5, + logLevel: LogLevel.Debug, + formatString: "Session stored; Key:{sessionKey}, Id:{sessionId}, Count:{count}"); + _sessionCacheReadException = LoggerMessage.Define( + eventId: 6, + logLevel: LogLevel.Error, + formatString: "Session cache read exception, Key:{sessionKey}"); + _errorUnprotectingCookie = LoggerMessage.Define( + eventId: 7, + logLevel: LogLevel.Warning, + formatString: "Error unprotecting the session cookie."); + _sessionLoadingTimeout = LoggerMessage.Define( + eventId: 8, + logLevel: LogLevel.Warning, + formatString: "Loading the session timed out."); + _sessionCommitTimeout = LoggerMessage.Define( + eventId: 9, + logLevel: LogLevel.Warning, + formatString: "Committing the session timed out."); + _sessionCommitCanceled = LoggerMessage.Define( + eventId: 10, + logLevel: LogLevel.Information, + formatString: "Committing the session was canceled."); + _sessionRefreshTimeout = LoggerMessage.Define( + eventId: 11, + logLevel: LogLevel.Warning, + formatString: "Refreshing the session timed out."); + _sessionRefreshCanceled = LoggerMessage.Define( + eventId: 12, + logLevel: LogLevel.Information, + formatString: "Refreshing the session was canceled."); + } + + public static void ErrorClosingTheSession(this ILogger logger, Exception exception) + { + _errorClosingTheSession(logger, exception); + } + + public static void AccessingExpiredSession(this ILogger logger, string sessionKey) + { + _accessingExpiredSession(logger, sessionKey, null); + } + + public static void SessionStarted(this ILogger logger, string sessionKey, string sessionId) + { + _sessionStarted(logger, sessionKey, sessionId, null); + } + + public static void SessionLoaded(this ILogger logger, string sessionKey, string sessionId, int count) + { + _sessionLoaded(logger, sessionKey, sessionId, count, null); + } + + public static void SessionStored(this ILogger logger, string sessionKey, string sessionId, int count) + { + _sessionStored(logger, sessionKey, sessionId, count, null); + } + + public static void SessionCacheReadException(this ILogger logger, string sessionKey, Exception exception) + { + _sessionCacheReadException(logger, sessionKey, exception); + } + + public static void ErrorUnprotectingSessionCookie(this ILogger logger, Exception exception) + { + _errorUnprotectingCookie(logger, exception); + } + + public static void SessionLoadingTimeout(this ILogger logger) + { + _sessionLoadingTimeout(logger, null); + } + + public static void SessionCommitTimeout(this ILogger logger) + { + _sessionCommitTimeout(logger, null); + } + + public static void SessionCommitCanceled(this ILogger logger) + { + _sessionCommitCanceled(logger, null); + } + + public static void SessionRefreshTimeout(this ILogger logger) + { + _sessionRefreshTimeout(logger, null); + } + + public static void SessionRefreshCanceled(this ILogger logger) + { + _sessionRefreshCanceled(logger, null); + } + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/Microsoft.AspNetCore.Session.csproj b/src/Session/src/Microsoft.AspNetCore.Session/Microsoft.AspNetCore.Session.csproj new file mode 100644 index 0000000000..3da00b9032 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/Microsoft.AspNetCore.Session.csproj @@ -0,0 +1,20 @@ + + + + ASP.NET Core session state middleware. + netstandard2.0 + $(NoWarn);CS1591 + true + true + aspnetcore;session;sessionstate + + + + + + + + + + + diff --git a/src/Session/src/Microsoft.AspNetCore.Session/NoOpSessionStore.cs b/src/Session/src/Microsoft.AspNetCore.Session/NoOpSessionStore.cs new file mode 100644 index 0000000000..6a89ad3900 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/NoOpSessionStore.cs @@ -0,0 +1,59 @@ +// 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.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Session +{ + internal class NoOpSessionStore : IDictionary + { + public byte[] this[EncodedKey key] + { + get + { + return null; + } + + set + { + + } + } + + public int Count { get; } = 0; + + public bool IsReadOnly { get; } = false; + + public ICollection Keys { get; } = new EncodedKey[0]; + + public ICollection Values { get; } = new byte[0][]; + + public void Add(KeyValuePair item) { } + + public void Add(EncodedKey key, byte[] value) { } + + public void Clear() { } + + public bool Contains(KeyValuePair item) => false; + + public bool ContainsKey(EncodedKey key) => false; + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { } + + public IEnumerator> GetEnumerator() => Enumerable.Empty>().GetEnumerator(); + + public bool Remove(KeyValuePair item) => false; + + public bool Remove(EncodedKey key) => false; + + public bool TryGetValue(EncodedKey key, out byte[] value) + { + value = null; + return false; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/Properties/Resources.Designer.cs b/src/Session/src/Microsoft.AspNetCore.Session/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..d071f6b698 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/Properties/Resources.Designer.cs @@ -0,0 +1,126 @@ +// +namespace Microsoft.AspNetCore.Session +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class Resources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.AspNetCore.Session.Resources", typeof(Resources).GetTypeInfo().Assembly); + + /// + /// The key cannot be longer than '{0}' when encoded with UTF-8. + /// + internal static string Exception_KeyLengthIsExceeded + { + get { return GetString("Exception_KeyLengthIsExceeded"); } + } + + /// + /// The key cannot be longer than '{0}' when encoded with UTF-8. + /// + internal static string FormatException_KeyLengthIsExceeded(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("Exception_KeyLengthIsExceeded"), p0); + } + + /// + /// The session cannot be established after the response has started. + /// + internal static string Exception_InvalidSessionEstablishment + { + get { return GetString("Exception_InvalidSessionEstablishment"); } + } + + /// + /// The session cannot be established after the response has started. + /// + internal static string FormatException_InvalidSessionEstablishment() + { + return GetString("Exception_InvalidSessionEstablishment"); + } + + /// + /// The value cannot be serialized in two bytes. + /// + internal static string Exception_InvalidToSerializeIn2Bytes + { + get { return GetString("Exception_InvalidToSerializeIn2Bytes"); } + } + + /// + /// The value cannot be serialized in two bytes. + /// + internal static string FormatException_InvalidToSerializeIn2Bytes() + { + return GetString("Exception_InvalidToSerializeIn2Bytes"); + } + + /// + /// The value cannot be serialized in three bytes. + /// + internal static string Exception_InvalidToSerializeIn3Bytes + { + get { return GetString("Exception_InvalidToSerializeIn3Bytes"); } + } + + /// + /// The value cannot be serialized in three bytes. + /// + internal static string FormatException_InvalidToSerializeIn3Bytes() + { + return GetString("Exception_InvalidToSerializeIn3Bytes"); + } + + /// + /// The value cannot be negative. + /// + internal static string Exception_NumberShouldNotBeNegative + { + get { return GetString("Exception_NumberShouldNotBeNegative"); } + } + + /// + /// The value cannot be negative. + /// + internal static string FormatException_NumberShouldNotBeNegative() + { + return GetString("Exception_NumberShouldNotBeNegative"); + } + + /// + /// Argument cannot be null or empty string. + /// + internal static string ArgumentCannotBeNullOrEmpty + { + get { return GetString("ArgumentCannotBeNullOrEmpty"); } + } + + /// + /// Argument cannot be null or empty string. + /// + internal static string FormatArgumentCannotBeNullOrEmpty() + { + return GetString("ArgumentCannotBeNullOrEmpty"); + } + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/Resources.resx b/src/Session/src/Microsoft.AspNetCore.Session/Resources.resx new file mode 100644 index 0000000000..5bb1c8e0bb --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/Resources.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The key cannot be longer than '{0}' when encoded with UTF-8. + + + The session cannot be established after the response has started. + + + The value cannot be serialized in two bytes. + + + The value cannot be serialized in three bytes. + + + The value cannot be negative. + + + Argument cannot be null or empty string. + + \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionDefaults.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionDefaults.cs new file mode 100644 index 0000000000..e9f590a0c7 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionDefaults.cs @@ -0,0 +1,21 @@ +// 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. + +namespace Microsoft.AspNetCore.Session +{ + /// + /// Represents defaults for the Session. + /// + public static class SessionDefaults + { + /// + /// Represent the default cookie name, which is ".AspNetCore.Session". + /// + public static readonly string CookieName = ".AspNetCore.Session"; + + /// + /// Represents the default path used to create the cookie, which is "/". + /// + public static readonly string CookiePath = "/"; + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionFeature.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionFeature.cs new file mode 100644 index 0000000000..44a378e614 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionFeature.cs @@ -0,0 +1,13 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.AspNetCore.Session +{ + public class SessionFeature : ISessionFeature + { + public ISession Session { get; set; } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddleware.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddleware.cs new file mode 100644 index 0000000000..bd74afa9cb --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddleware.cs @@ -0,0 +1,174 @@ +// 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; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Session +{ + /// + /// Enables the session state for the application. + /// + public class SessionMiddleware + { + private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create(); + private const int SessionKeyLength = 36; // "382c74c3-721d-4f34-80e5-57657b6cbc27" + private static readonly Func ReturnTrue = () => true; + private readonly RequestDelegate _next; + private readonly SessionOptions _options; + private readonly ILogger _logger; + private readonly ISessionStore _sessionStore; + private readonly IDataProtector _dataProtector; + + /// + /// Creates a new . + /// + /// The representing the next middleware in the pipeline. + /// The representing the factory that used to create logger instances. + /// The used to protect and verify the cookie. + /// The representing the session store. + /// The session configuration options. + public SessionMiddleware( + RequestDelegate next, + ILoggerFactory loggerFactory, + IDataProtectionProvider dataProtectionProvider, + ISessionStore sessionStore, + IOptions options) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + if (dataProtectionProvider == null) + { + throw new ArgumentNullException(nameof(dataProtectionProvider)); + } + + if (sessionStore == null) + { + throw new ArgumentNullException(nameof(sessionStore)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _next = next; + _logger = loggerFactory.CreateLogger(); + _dataProtector = dataProtectionProvider.CreateProtector(nameof(SessionMiddleware)); + _options = options.Value; + _sessionStore = sessionStore; + } + + /// + /// Invokes the logic of the middleware. + /// + /// The . + /// A that completes when the middleware has completed processing. + public async Task Invoke(HttpContext context) + { + var isNewSessionKey = false; + Func tryEstablishSession = ReturnTrue; + var cookieValue = context.Request.Cookies[_options.Cookie.Name]; + var sessionKey = CookieProtection.Unprotect(_dataProtector, cookieValue, _logger); + if (string.IsNullOrWhiteSpace(sessionKey) || sessionKey.Length != SessionKeyLength) + { + // No valid cookie, new session. + var guidBytes = new byte[16]; + CryptoRandom.GetBytes(guidBytes); + sessionKey = new Guid(guidBytes).ToString(); + cookieValue = CookieProtection.Protect(_dataProtector, sessionKey); + var establisher = new SessionEstablisher(context, cookieValue, _options); + tryEstablishSession = establisher.TryEstablishSession; + isNewSessionKey = true; + } + + var feature = new SessionFeature(); + feature.Session = _sessionStore.Create(sessionKey, _options.IdleTimeout, _options.IOTimeout, tryEstablishSession, isNewSessionKey); + context.Features.Set(feature); + + try + { + await _next(context); + } + finally + { + context.Features.Set(null); + + if (feature.Session != null) + { + try + { + await feature.Session.CommitAsync(context.RequestAborted); + } + catch (OperationCanceledException) + { + _logger.SessionCommitCanceled(); + } + catch (Exception ex) + { + _logger.ErrorClosingTheSession(ex); + } + } + } + } + + private class SessionEstablisher + { + private readonly HttpContext _context; + private readonly string _cookieValue; + private readonly SessionOptions _options; + private bool _shouldEstablishSession; + + public SessionEstablisher(HttpContext context, string cookieValue, SessionOptions options) + { + _context = context; + _cookieValue = cookieValue; + _options = options; + context.Response.OnStarting(OnStartingCallback, state: this); + } + + private static Task OnStartingCallback(object state) + { + var establisher = (SessionEstablisher)state; + if (establisher._shouldEstablishSession) + { + establisher.SetCookie(); + } + return Task.FromResult(0); + } + + private void SetCookie() + { + var cookieOptions = _options.Cookie.Build(_context); + + _context.Response.Cookies.Append(_options.Cookie.Name, _cookieValue, cookieOptions); + + _context.Response.Headers["Cache-Control"] = "no-cache"; + _context.Response.Headers["Pragma"] = "no-cache"; + _context.Response.Headers["Expires"] = "-1"; + } + + // Returns true if the session has already been established, or if it still can be because the response has not been sent. + internal bool TryEstablishSession() + { + return (_shouldEstablishSession |= !_context.Response.HasStarted); + } + } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddlewareExtensions.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddlewareExtensions.cs new file mode 100644 index 0000000000..c273124379 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionMiddlewareExtensions.cs @@ -0,0 +1,50 @@ +// 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; +using Microsoft.AspNetCore.Session; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for adding the to an application. + /// + public static class SessionMiddlewareExtensions + { + /// + /// Adds the to automatically enable session state for the application. + /// + /// The . + /// The . + public static IApplicationBuilder UseSession(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.UseMiddleware(); + } + + /// + /// Adds the to automatically enable session state for the application. + /// + /// The . + /// The . + /// The . + public static IApplicationBuilder UseSession(this IApplicationBuilder app, SessionOptions options) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + return app.UseMiddleware(Options.Create(options)); + } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionOptions.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionOptions.cs new file mode 100644 index 0000000000..4d456b0f60 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionOptions.cs @@ -0,0 +1,126 @@ +// 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; +using System.Threading; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Session; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Represents the session state options for the application. + /// + public class SessionOptions + { + private CookieBuilder _cookieBuilder = new SessionCookieBuilder(); + + /// + /// Determines the settings used to create the cookie. + /// + /// defaults to . + /// defaults to . + /// defaults to . + /// defaults to true + /// defaults to false + /// + /// + public CookieBuilder Cookie + { + get => _cookieBuilder; + set => _cookieBuilder = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// The IdleTimeout indicates how long the session can be idle before its contents are abandoned. Each session access + /// resets the timeout. Note this only applies to the content of the session, not the cookie. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(20); + + /// + /// The maximim amount of time allowed to load a session from the store or to commit it back to the store. + /// Note this may only apply to asynchronous operations. This timeout can be disabled using . + /// + public TimeSpan IOTimeout { get; set; } = TimeSpan.FromMinutes(1); + + #region Obsolete API + /// + /// + /// This property is obsolete and will be removed in a future version. The recommended alternative is on . + /// + /// + /// Determines the cookie name used to persist the session ID. + /// + /// + [Obsolete("This property is obsolete and will be removed in a future version. The recommended alternative is " + nameof(Cookie) + "." + nameof(CookieBuilder.Name) + ".")] + public string CookieName { get => Cookie.Name; set => Cookie.Name = value; } + + /// + /// + /// This property is obsolete and will be removed in a future version. The recommended alternative is on . + /// + /// + /// Determines the domain used to create the cookie. Is not provided by default. + /// + /// + [Obsolete("This property is obsolete and will be removed in a future version. The recommended alternative is " + nameof(Cookie) + "." + nameof(CookieBuilder.Domain) + ".")] + public string CookieDomain { get => Cookie.Domain; set => Cookie.Domain = value; } + + /// + /// + /// This property is obsolete and will be removed in a future version. The recommended alternative is on . + /// + /// + /// Determines the path used to create the cookie. + /// Defaults to . + /// + /// + [Obsolete("This property is obsolete and will be removed in a future version. The recommended alternative is " + nameof(Cookie) + "." + nameof(CookieBuilder.Path) + ".")] + public string CookiePath { get => Cookie.Path; set => Cookie.Path = value; } + + /// + /// + /// This property is obsolete and will be removed in a future version. The recommended alternative is on . + /// + /// + /// Determines if the browser should allow the cookie to be accessed by client-side JavaScript. The + /// default is true, which means the cookie will only be passed to HTTP requests and is not made available + /// to script on the page. + /// + /// + [Obsolete("This property is obsolete and will be removed in a future version. The recommended alternative is " + nameof(Cookie) + "." + nameof(CookieBuilder.HttpOnly) + ".")] + public bool CookieHttpOnly { get => Cookie.HttpOnly; set => Cookie.HttpOnly = value; } + + /// + /// + /// This property is obsolete and will be removed in a future version. The recommended alternative is on . + /// + /// + /// Determines if the cookie should only be transmitted on HTTPS requests. + /// + /// + [Obsolete("This property is obsolete and will be removed in a future version. The recommended alternative is " + nameof(Cookie) + "." + nameof(CookieBuilder.SecurePolicy) + ".")] + public CookieSecurePolicy CookieSecure { get => Cookie.SecurePolicy; set => Cookie.SecurePolicy = value; } + #endregion + + private class SessionCookieBuilder : CookieBuilder + { + public SessionCookieBuilder() + { + Name = SessionDefaults.CookieName; + Path = SessionDefaults.CookiePath; + SecurePolicy = CookieSecurePolicy.None; + SameSite = SameSiteMode.Lax; + HttpOnly = true; + // Session is considered non-essential as it's designed for ephemeral data. + IsEssential = false; + } + + public override TimeSpan? Expiration + { + get => null; + set => throw new InvalidOperationException(nameof(Expiration) + " cannot be set for the cookie defined by " + nameof(SessionOptions)); + } + } + } +} diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SessionServiceCollectionExtensions.cs b/src/Session/src/Microsoft.AspNetCore.Session/SessionServiceCollectionExtensions.cs new file mode 100644 index 0000000000..628390fbe3 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SessionServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// 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; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Session; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for adding session services to the DI container. + /// + public static class SessionServiceCollectionExtensions + { + /// + /// Adds services required for application session state. + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddSession(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.TryAddTransient(); + services.AddDataProtection(); + return services; + } + + /// + /// Adds services required for application session state. + /// + /// The to add the services to. + /// The session options to configure the middleware with. + /// The so that additional calls can be chained. + public static IServiceCollection AddSession(this IServiceCollection services, Action configure) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + services.Configure(configure); + services.AddSession(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/SipHash.cs b/src/Session/src/Microsoft.AspNetCore.Session/SipHash.cs new file mode 100644 index 0000000000..bad98fcab3 --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/SipHash.cs @@ -0,0 +1,202 @@ +// 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. + +namespace Microsoft.AspNetCore.Session +{ + // A byte[] equality comparer based on the SipHash-2-4 algorithm. Key differences: + // (a) we output 32-bit hashes instead of 64-bit hashes, and + // (b) we don't care about endianness since hashes are used only in hash tables + // and aren't returned to user code. + // + // Derived from the implementation in SignalR and modified to address byte[] instead of string. This derived version is not for cryptographic use, just hash codes. + // https://github.com/aspnet/SignalR-Server/blob/75f74169c81a51780f195d06b798302b2d76dbde/src/Microsoft.AspNetCore.SignalR.Server/Infrastructure/SipHashBasedStringEqualityComparer.cs + // Derivative work of https://github.com/tanglebones/ch-siphash. + internal static class SipHash + { + internal static int GetHashCode(byte[] bytes) + { + // Assume SipHash-2-4 is a strong PRF, therefore truncation to 32 bits is acceptable. + return (int)SipHash_2_4_UlongCast_ForcedInline(bytes); + } + + private static ulong SipHash_2_4_UlongCast_ForcedInline(byte[] bytes) + { + unsafe + { + ulong v0 = 0x736f6d6570736575; + ulong v1 = 0x646f72616e646f6d; + ulong v2 = 0x6c7967656e657261; + ulong v3 = 0x7465646279746573; + + uint inlen = (uint)bytes.Length; + fixed (byte* finb = bytes) + { + var b = ((ulong)inlen) << 56; + + if (inlen > 0) + { + var inb = finb; + var left = inlen & 7; + var end = inb + inlen - left; + var linb = (ulong*)finb; + var lend = (ulong*)end; + for (; linb < lend; ++linb) + { + v3 ^= *linb; + + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + + v0 ^= *linb; + } + for (var i = 0; i < left; ++i) + { + b |= ((ulong)end[i]) << (8 * i); + } + } + + v3 ^= b; + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 ^= b; + v2 ^= 0xff; + + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + v0 += v1; + v1 = (v1 << 13) | (v1 >> (64 - 13)); + v1 ^= v0; + v0 = (v0 << 32) | (v0 >> (64 - 32)); + + v2 += v3; + v3 = (v3 << 16) | (v3 >> (64 - 16)); + v3 ^= v2; + + v0 += v3; + v3 = (v3 << 21) | (v3 >> (64 - 21)); + v3 ^= v0; + + v2 += v1; + v1 = (v1 << 17) | (v1 >> (64 - 17)); + v1 ^= v2; + v2 = (v2 << 32) | (v2 >> (64 - 32)); + } + + return v0 ^ v1 ^ v2 ^ v3; + } + } + } +} \ No newline at end of file diff --git a/src/Session/src/Microsoft.AspNetCore.Session/baseline.netcore.json b/src/Session/src/Microsoft.AspNetCore.Session/baseline.netcore.json new file mode 100644 index 0000000000..ff9ee6811f --- /dev/null +++ b/src/Session/src/Microsoft.AspNetCore.Session/baseline.netcore.json @@ -0,0 +1,687 @@ +{ + "AssemblyIdentity": "Microsoft.AspNetCore.Session, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + { + "Name": "Microsoft.Extensions.DependencyInjection.SessionServiceCollectionExtensions", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "AddSession", + "Parameters": [ + { + "Name": "services", + "Type": "Microsoft.Extensions.DependencyInjection.IServiceCollection" + } + ], + "ReturnType": "Microsoft.Extensions.DependencyInjection.IServiceCollection", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddSession", + "Parameters": [ + { + "Name": "services", + "Type": "Microsoft.Extensions.DependencyInjection.IServiceCollection" + }, + { + "Name": "configure", + "Type": "System.Action" + } + ], + "ReturnType": "Microsoft.Extensions.DependencyInjection.IServiceCollection", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Builder.SessionMiddlewareExtensions", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "UseSession", + "Parameters": [ + { + "Name": "app", + "Type": "Microsoft.AspNetCore.Builder.IApplicationBuilder" + } + ], + "ReturnType": "Microsoft.AspNetCore.Builder.IApplicationBuilder", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "UseSession", + "Parameters": [ + { + "Name": "app", + "Type": "Microsoft.AspNetCore.Builder.IApplicationBuilder" + }, + { + "Name": "options", + "Type": "Microsoft.AspNetCore.Builder.SessionOptions" + } + ], + "ReturnType": "Microsoft.AspNetCore.Builder.IApplicationBuilder", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Builder.SessionOptions", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_Cookie", + "Parameters": [], + "ReturnType": "Microsoft.AspNetCore.Http.CookieBuilder", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Cookie", + "Parameters": [ + { + "Name": "value", + "Type": "Microsoft.AspNetCore.Http.CookieBuilder" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_IdleTimeout", + "Parameters": [], + "ReturnType": "System.TimeSpan", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_IdleTimeout", + "Parameters": [ + { + "Name": "value", + "Type": "System.TimeSpan" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_IOTimeout", + "Parameters": [], + "ReturnType": "System.TimeSpan", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_IOTimeout", + "Parameters": [ + { + "Name": "value", + "Type": "System.TimeSpan" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_CookieName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_CookieName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_CookieDomain", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_CookieDomain", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_CookiePath", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_CookiePath", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_CookieHttpOnly", + "Parameters": [], + "ReturnType": "System.Boolean", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_CookieHttpOnly", + "Parameters": [ + { + "Name": "value", + "Type": "System.Boolean" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_CookieSecure", + "Parameters": [], + "ReturnType": "Microsoft.AspNetCore.Http.CookieSecurePolicy", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_CookieSecure", + "Parameters": [ + { + "Name": "value", + "Type": "Microsoft.AspNetCore.Http.CookieSecurePolicy" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.DistributedSession", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.AspNetCore.Http.ISession" + ], + "Members": [ + { + "Kind": "Method", + "Name": "get_IsAvailable", + "Parameters": [], + "ReturnType": "System.Boolean", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Id", + "Parameters": [], + "ReturnType": "System.String", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Keys", + "Parameters": [], + "ReturnType": "System.Collections.Generic.IEnumerable", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "TryGetValue", + "Parameters": [ + { + "Name": "key", + "Type": "System.String" + }, + { + "Name": "value", + "Type": "System.Byte[]", + "Direction": "Out" + } + ], + "ReturnType": "System.Boolean", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Set", + "Parameters": [ + { + "Name": "key", + "Type": "System.String" + }, + { + "Name": "value", + "Type": "System.Byte[]" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Remove", + "Parameters": [ + { + "Name": "key", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Clear", + "Parameters": [], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "LoadAsync", + "Parameters": [ + { + "Name": "cancellationToken", + "Type": "System.Threading.CancellationToken", + "DefaultValue": "default(System.Threading.CancellationToken)" + } + ], + "ReturnType": "System.Threading.Tasks.Task", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "CommitAsync", + "Parameters": [ + { + "Name": "cancellationToken", + "Type": "System.Threading.CancellationToken", + "DefaultValue": "default(System.Threading.CancellationToken)" + } + ], + "ReturnType": "System.Threading.Tasks.Task", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.ISession", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "cache", + "Type": "Microsoft.Extensions.Caching.Distributed.IDistributedCache" + }, + { + "Name": "sessionKey", + "Type": "System.String" + }, + { + "Name": "idleTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "ioTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "tryEstablishSession", + "Type": "System.Func" + }, + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + }, + { + "Name": "isNewSessionKey", + "Type": "System.Boolean" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.DistributedSessionStore", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.AspNetCore.Session.ISessionStore" + ], + "Members": [ + { + "Kind": "Method", + "Name": "Create", + "Parameters": [ + { + "Name": "sessionKey", + "Type": "System.String" + }, + { + "Name": "idleTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "ioTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "tryEstablishSession", + "Type": "System.Func" + }, + { + "Name": "isNewSessionKey", + "Type": "System.Boolean" + } + ], + "ReturnType": "Microsoft.AspNetCore.Http.ISession", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Session.ISessionStore", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "cache", + "Type": "Microsoft.Extensions.Caching.Distributed.IDistributedCache" + }, + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.ISessionStore", + "Visibility": "Public", + "Kind": "Interface", + "Abstract": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "Create", + "Parameters": [ + { + "Name": "sessionKey", + "Type": "System.String" + }, + { + "Name": "idleTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "ioTimeout", + "Type": "System.TimeSpan" + }, + { + "Name": "tryEstablishSession", + "Type": "System.Func" + }, + { + "Name": "isNewSessionKey", + "Type": "System.Boolean" + } + ], + "ReturnType": "Microsoft.AspNetCore.Http.ISession", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.SessionDefaults", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Field", + "Name": "CookieName", + "Parameters": [], + "ReturnType": "System.String", + "Static": true, + "ReadOnly": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Field", + "Name": "CookiePath", + "Parameters": [], + "ReturnType": "System.String", + "Static": true, + "ReadOnly": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.SessionFeature", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.AspNetCore.Http.Features.ISessionFeature" + ], + "Members": [ + { + "Kind": "Method", + "Name": "get_Session", + "Parameters": [], + "ReturnType": "Microsoft.AspNetCore.Http.ISession", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.Features.ISessionFeature", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Session", + "Parameters": [ + { + "Name": "value", + "Type": "Microsoft.AspNetCore.Http.ISession" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.AspNetCore.Http.Features.ISessionFeature", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.AspNetCore.Session.SessionMiddleware", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "Invoke", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.AspNetCore.Http.HttpContext" + } + ], + "ReturnType": "System.Threading.Tasks.Task", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "next", + "Type": "Microsoft.AspNetCore.Http.RequestDelegate" + }, + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + }, + { + "Name": "dataProtectionProvider", + "Type": "Microsoft.AspNetCore.DataProtection.IDataProtectionProvider" + }, + { + "Name": "sessionStore", + "Type": "Microsoft.AspNetCore.Session.ISessionStore" + }, + { + "Name": "options", + "Type": "Microsoft.Extensions.Options.IOptions" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + } + ] +} \ No newline at end of file diff --git a/src/Session/test/Directory.Build.props b/src/Session/test/Directory.Build.props new file mode 100644 index 0000000000..270e1fa209 --- /dev/null +++ b/src/Session/test/Directory.Build.props @@ -0,0 +1,14 @@ + + + + + netcoreapp2.1 + $(DeveloperBuildTestTfms) + netcoreapp2.1;netcoreapp2.0 + $(StandardTestTfms);net461 + + + + + + diff --git a/src/Session/test/Microsoft.AspNetCore.Session.Tests/Microsoft.AspNetCore.Session.Tests.csproj b/src/Session/test/Microsoft.AspNetCore.Session.Tests/Microsoft.AspNetCore.Session.Tests.csproj new file mode 100644 index 0000000000..9dc11b9d2f --- /dev/null +++ b/src/Session/test/Microsoft.AspNetCore.Session.Tests/Microsoft.AspNetCore.Session.Tests.csproj @@ -0,0 +1,22 @@ + + + + $(StandardTestTfms) + true + true + + + + + + + + + + + + + + + + diff --git a/src/Session/test/Microsoft.AspNetCore.Session.Tests/SessionTests.cs b/src/Session/test/Microsoft.AspNetCore.Session.Tests/SessionTests.cs new file mode 100644 index 0000000000..f98a018bc5 --- /dev/null +++ b/src/Session/test/Microsoft.AspNetCore.Session.Tests/SessionTests.cs @@ -0,0 +1,1016 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Session +{ + public class SessionTests + { + [Fact] + public async Task ReadingEmptySessionDoesNotCreateCookie() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + + app.Run(context => + { + Assert.Null(context.Session.GetString("NotFound")); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + Assert.False(response.Headers.TryGetValues("Set-Cookie", out var _)); + } + } + + [Fact] + public async Task SettingAValueCausesTheCookieToBeCreated() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + Assert.Null(context.Session.GetString("Key")); + context.Session.SetString("Key", "Value"); + Assert.Equal("Value", context.Session.GetString("Key")); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var values)); + Assert.Single(values); + Assert.True(!string.IsNullOrWhiteSpace(values.First())); + } + } + + [Theory] + [InlineData(CookieSecurePolicy.Always, "http://example.com/testpath", true)] + [InlineData(CookieSecurePolicy.Always, "https://example.com/testpath", true)] + [InlineData(CookieSecurePolicy.None, "http://example.com/testpath", false)] + [InlineData(CookieSecurePolicy.None, "https://example.com/testpath", false)] + [InlineData(CookieSecurePolicy.SameAsRequest, "http://example.com/testpath", false)] + [InlineData(CookieSecurePolicy.SameAsRequest, "https://example.com/testpath", true)] + public async Task SecureSessionBasedOnHttpsAndSecurePolicy( + CookieSecurePolicy cookieSecurePolicy, + string requestUri, + bool shouldBeSecureOnly) + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(new SessionOptions + { + Cookie = + { + Name = "TestCookie", + SecurePolicy = cookieSecurePolicy + } + }); + app.Run(context => + { + Assert.Null(context.Session.GetString("Key")); + context.Session.SetString("Key", "Value"); + Assert.Equal("Value", context.Session.GetString("Key")); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(requestUri); + response.EnsureSuccessStatusCode(); + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var values)); + Assert.Single(values); + if (shouldBeSecureOnly) + { + Assert.Contains("; secure", values.First()); + } + else + { + Assert.DoesNotContain("; secure", values.First()); + } + } + } + + [Fact] + public async Task SessionCanBeAccessedOnTheNextRequest() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + int? value = context.Session.GetInt32("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + } + Assert.True(value.HasValue); + context.Session.SetInt32("Key", value.Value + 1); + return context.Response.WriteAsync(value.Value.ToString()); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync("first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + var cookie = SetCookieHeaderValue.ParseList(response.Headers.GetValues("Set-Cookie").ToList()).First(); + client.DefaultRequestHeaders.Add("Cookie", new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + Assert.Equal("1", await client.GetStringAsync("/")); + Assert.Equal("2", await client.GetStringAsync("/")); + Assert.Equal("3", await client.GetStringAsync("/")); + } + } + + [Fact] + public async Task RemovedItemCannotBeAccessedAgain() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + int? value = context.Session.GetInt32("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + context.Session.SetInt32("Key", 1); + } + else if (context.Request.Path == new PathString("/second")) + { + Assert.True(value.HasValue); + Assert.Equal(1, value); + context.Session.Remove("Key"); + } + else if (context.Request.Path == new PathString("/third")) + { + Assert.False(value.HasValue); + value = 2; + } + return context.Response.WriteAsync(value.Value.ToString()); + }); + }) + .ConfigureServices( + services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync("first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + var cookie = SetCookieHeaderValue.ParseList(response.Headers.GetValues("Set-Cookie").ToList()).First(); + client.DefaultRequestHeaders.Add("Cookie", new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + Assert.Equal("1", await client.GetStringAsync("/second")); + Assert.Equal("2", await client.GetStringAsync("/third")); + } + } + + [Fact] + public async Task ClearedItemsCannotBeAccessedAgain() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + int? value = context.Session.GetInt32("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + context.Session.SetInt32("Key", 1); + } + else if (context.Request.Path == new PathString("/second")) + { + Assert.True(value.HasValue); + Assert.Equal(1, value); + context.Session.Clear(); + } + else if (context.Request.Path == new PathString("/third")) + { + Assert.False(value.HasValue); + value = 2; + } + return context.Response.WriteAsync(value.Value.ToString()); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync("first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + var cookie = SetCookieHeaderValue.ParseList(response.Headers.GetValues("Set-Cookie").ToList()).First(); + client.DefaultRequestHeaders.Add("Cookie", new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + Assert.Equal("1", await client.GetStringAsync("/second")); + Assert.Equal("2", await client.GetStringAsync("/third")); + } + } + + [Fact] + public async Task SessionStart_LogsInformation() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + context.Session.SetString("Key", "Value"); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var sessionLogMessages = sink.Writes.ToList(); + + Assert.Equal(2, sessionLogMessages.Count); + Assert.Contains("started", sessionLogMessages[0].State.ToString()); + Assert.Equal(LogLevel.Information, sessionLogMessages[0].LogLevel); + Assert.Contains("stored", sessionLogMessages[1].State.ToString()); + Assert.Equal(LogLevel.Debug, sessionLogMessages[1].LogLevel); + } + + [Fact] + public async Task ExpiredSession_LogsInfo() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + int? value = context.Session.GetInt32("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 1; + context.Session.SetInt32("Key", 1); + } + else if (context.Request.Path == new PathString("/second")) + { + Assert.False(value.HasValue); + value = 2; + } + return context.Response.WriteAsync(value.Value.ToString()); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddDistributedMemoryCache(); + services.AddSession(o => o.IdleTimeout = TimeSpan.FromMilliseconds(30)); + }); + + string result; + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync("first"); + response.EnsureSuccessStatusCode(); + + client = server.CreateClient(); + var cookie = SetCookieHeaderValue.ParseList(response.Headers.GetValues("Set-Cookie").ToList()).First(); + client.DefaultRequestHeaders.Add("Cookie", new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + Thread.Sleep(50); + result = await client.GetStringAsync("/second"); + } + + var sessionLogMessages = sink.Writes.ToList(); + + Assert.Equal("2", result); + Assert.Equal(3, sessionLogMessages.Count); + Assert.Contains("started", sessionLogMessages[0].State.ToString()); + Assert.Contains("stored", sessionLogMessages[1].State.ToString()); + Assert.Contains("expired", sessionLogMessages[2].State.ToString()); + Assert.Equal(LogLevel.Information, sessionLogMessages[0].LogLevel); + Assert.Equal(LogLevel.Debug, sessionLogMessages[1].LogLevel); + Assert.Equal(LogLevel.Information, sessionLogMessages[2].LogLevel); + } + + [Fact] + public async Task RefreshesSession_WhenSessionData_IsNotModified() + { + var clock = new TestClock(); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + string responseData = string.Empty; + if (context.Request.Path == new PathString("/AddDataToSession")) + { + context.Session.SetInt32("Key", 10); + responseData = "added data to session"; + } + else if (context.Request.Path == new PathString("/AccessSessionData")) + { + var value = context.Session.GetInt32("Key"); + responseData = (value == null) ? "No value found in session." : value.ToString(); + } + else if (context.Request.Path == new PathString("/DoNotAccessSessionData")) + { + responseData = "did not access session data"; + } + + return context.Response.WriteAsync(responseData); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), NullLoggerFactory.Instance); + services.AddDistributedMemoryCache(); + services.AddSession(o => o.IdleTimeout = TimeSpan.FromMinutes(20)); + services.Configure(o => o.Clock = clock); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync("AddDataToSession"); + response.EnsureSuccessStatusCode(); + + client = server.CreateClient(); + var cookie = SetCookieHeaderValue.ParseList(response.Headers.GetValues("Set-Cookie").ToList()).First(); + client.DefaultRequestHeaders.Add( + "Cookie", new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + + for (var i = 0; i < 5; i++) + { + clock.Add(TimeSpan.FromMinutes(10)); + await client.GetStringAsync("/DoNotAccessSessionData"); + } + + var data = await client.GetStringAsync("/AccessSessionData"); + Assert.Equal("10", data); + } + } + + [Fact] + public async Task SessionFeature_IsUnregistered_WhenResponseGoingOut() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(async (httpContext, next) => + { + await next(); + + Assert.Null(httpContext.Features.Get()); + }); + + app.UseSession(); + + app.Run(context => + { + context.Session.SetString("key", "value"); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + } + + [Fact] + public async Task SessionFeature_IsUnregistered_WhenResponseGoingOut_AndAnUnhandledExcetionIsThrown() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use(async (httpContext, next) => + { + var exceptionThrown = false; + try + { + await next(); + } + catch + { + exceptionThrown = true; + } + + Assert.True(exceptionThrown); + Assert.Null(httpContext.Features.Get()); + }); + + app.UseSession(); + + app.Run(context => + { + throw new InvalidOperationException("An error occurred."); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + } + } + + [Fact] + public async Task SessionKeys_AreCaseSensitive() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + context.Session.SetString("KEY", "VALUE"); + context.Session.SetString("key", "value"); + Assert.Equal("VALUE", context.Session.GetString("KEY")); + Assert.Equal("value", context.Session.GetString("key")); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddDistributedMemoryCache(); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + } + + [Fact] + public async Task SessionLogsCacheReadException() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + Assert.False(context.Session.TryGetValue("key", out var value)); + Assert.Null(value); + Assert.Equal(string.Empty, context.Session.Id); + Assert.False(context.Session.Keys.Any()); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DisableGet = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var message = Assert.Single(sink.Writes); + Assert.Contains("Session cache read exception", message.State.ToString()); + Assert.Equal(LogLevel.Error, message.LogLevel); + } + + [Fact] + public async Task SessionLogsCacheLoadAsyncException() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(async context => + { + await Assert.ThrowsAsync(() => context.Session.LoadAsync()); + Assert.False(context.Session.IsAvailable); + Assert.Equal(string.Empty, context.Session.Id); + Assert.False(context.Session.Keys.Any()); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DisableGet = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var message = Assert.Single(sink.Writes); + Assert.Contains("Session cache read exception", message.State.ToString()); + Assert.Equal(LogLevel.Error, message.LogLevel); + } + + [Fact] + public async Task SessionLogsCacheLoadAsyncTimeoutException() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(new SessionOptions() + { + IOTimeout = TimeSpan.FromSeconds(0.5) + }); + app.Run(async context => + { + await Assert.ThrowsAsync(() => context.Session.LoadAsync()); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DelayGetAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var message = Assert.Single(sink.Writes); + Assert.Contains("Loading the session timed out.", message.State.ToString()); + Assert.Equal(LogLevel.Warning, message.LogLevel); + } + + [Fact] + public async Task SessionLoadAsyncCanceledException() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(async context => + { + var cts = new CancellationTokenSource(); + var token = cts.Token; + cts.Cancel(); + await Assert.ThrowsAsync(() => context.Session.LoadAsync(token)); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DelayGetAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + Assert.Empty(sink.Writes); + } + + [Fact] + public async Task SessionLogsCacheCommitException() + { + var sink = new TestSink( + writeContext => + { + return writeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || writeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }, + beginScopeContext => + { + return beginScopeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || beginScopeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + context.Session.SetInt32("key", 0); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DisableSetAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var sessionLogMessage = sink.Writes.Where(message => message.LoggerName.Equals(typeof(DistributedSession).FullName, StringComparison.Ordinal)).Single(); + + Assert.Contains("Session started", sessionLogMessage.State.ToString()); + Assert.Equal(LogLevel.Information, sessionLogMessage.LogLevel); + + var sessionMiddlewareLogMessage = sink.Writes.Where(message => message.LoggerName.Equals(typeof(SessionMiddleware).FullName, StringComparison.Ordinal)).Single(); + + Assert.Contains("Error closing the session.", sessionMiddlewareLogMessage.State.ToString()); + Assert.Equal(LogLevel.Error, sessionMiddlewareLogMessage.LogLevel); + } + + [Fact] + public async Task SessionLogsCacheCommitTimeoutException() + { + var sink = new TestSink( + writeContext => + { + return writeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || writeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }, + beginScopeContext => + { + return beginScopeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || beginScopeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(new SessionOptions() + { + IOTimeout = TimeSpan.FromSeconds(0.5) + }); + app.Run(context => + { + context.Session.SetInt32("key", 0); + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DelaySetAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var sessionLogMessages = sink.Writes.Where(message => message.LoggerName.Equals(typeof(DistributedSession).FullName, StringComparison.Ordinal)).ToList(); + + Assert.Contains("Session started", sessionLogMessages[0].State.ToString()); + Assert.Equal(LogLevel.Information, sessionLogMessages[0].LogLevel); + + Assert.Contains("Committing the session timed out.", sessionLogMessages[1].State.ToString()); + Assert.Equal(LogLevel.Warning, sessionLogMessages[1].LogLevel); + + var sessionMiddlewareLogs = sink.Writes.Where(message => message.LoggerName.Equals(typeof(SessionMiddleware).FullName, StringComparison.Ordinal)).ToList(); + + Assert.Contains("Committing the session was canceled.", sessionMiddlewareLogs[0].State.ToString()); + Assert.Equal(LogLevel.Information, sessionMiddlewareLogs[0].LogLevel); + } + + [Fact] + public async Task SessionLogsCacheCommitCanceledException() + { + var sink = new TestSink( + writeContext => + { + return writeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || writeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }, + beginScopeContext => + { + return beginScopeContext.LoggerName.Equals(typeof(SessionMiddleware).FullName) + || beginScopeContext.LoggerName.Equals(typeof(DistributedSession).FullName); + }); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(async context => + { + context.Session.SetInt32("key", 0); + var cts = new CancellationTokenSource(); + var token = cts.Token; + cts.Cancel(); + await Assert.ThrowsAsync(() => context.Session.CommitAsync(token)); + context.RequestAborted = token; + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DelaySetAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + Assert.Empty(sink.Writes.Where(message => message.LoggerName.Equals(typeof(DistributedSession).FullName, StringComparison.Ordinal))); + + var sessionMiddlewareLogs = sink.Writes.Where(message => message.LoggerName.Equals(typeof(SessionMiddleware).FullName, StringComparison.Ordinal)).ToList(); + + Assert.Contains("Committing the session was canceled.", sessionMiddlewareLogs[0].State.ToString()); + Assert.Equal(LogLevel.Information, sessionMiddlewareLogs[0].LogLevel); + } + + [Fact] + public async Task SessionLogsCacheRefreshException() + { + var sink = new TestSink( + TestSink.EnableWithTypeName, + TestSink.EnableWithTypeName); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseSession(); + app.Run(context => + { + // The middleware calls context.Session.CommitAsync() once per request + return Task.FromResult(0); + }); + }) + .ConfigureServices(services => + { + services.AddSingleton(typeof(ILoggerFactory), loggerFactory); + services.AddSingleton(new UnreliableCache(new MemoryCache(new MemoryCacheOptions())) + { + DisableRefreshAsync = true + }); + services.AddSession(); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + var response = await client.GetAsync(string.Empty); + response.EnsureSuccessStatusCode(); + } + + var message = Assert.Single(sink.Writes); + Assert.Contains("Error closing the session.", message.State.ToString()); + Assert.Equal(LogLevel.Error, message.LogLevel); + } + + private class TestClock : ISystemClock + { + public TestClock() + { + UtcNow = new DateTimeOffset(2013, 1, 1, 1, 0, 0, TimeSpan.Zero); + } + + public DateTimeOffset UtcNow { get; private set; } + + public void Add(TimeSpan timespan) + { + UtcNow = UtcNow.Add(timespan); + } + } + + private class UnreliableCache : IDistributedCache + { + private readonly MemoryDistributedCache _cache; + + public bool DisableGet { get; set; } + public bool DisableSetAsync { get; set; } + public bool DisableRefreshAsync { get; set; } + public bool DelayGetAsync { get; set; } + public bool DelaySetAsync { get; set; } + public bool DelayRefreshAsync { get; set; } + + public UnreliableCache(IMemoryCache memoryCache) + { + _cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + } + + public byte[] Get(string key) + { + if (DisableGet) + { + throw new InvalidOperationException(); + } + return _cache.Get(key); + } + + public Task GetAsync(string key, CancellationToken token = default) + { + if (DisableGet) + { + throw new InvalidOperationException(); + } + if (DelayGetAsync) + { + token.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)); + token.ThrowIfCancellationRequested(); + } + return _cache.GetAsync(key, token); + } + + public void Refresh(string key) => _cache.Refresh(key); + + public Task RefreshAsync(string key, CancellationToken token = default) + { + if (DisableRefreshAsync) + { + throw new InvalidOperationException(); + } + if (DelayRefreshAsync) + { + token.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)); + token.ThrowIfCancellationRequested(); + } + return _cache.RefreshAsync(key); + } + + public void Remove(string key) => _cache.Remove(key); + + public Task RemoveAsync(string key, CancellationToken token = default) => _cache.RemoveAsync(key); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => _cache.Set(key, value, options); + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + if (DisableSetAsync) + { + throw new InvalidOperationException(); + } + if (DelaySetAsync) + { + token.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)); + token.ThrowIfCancellationRequested(); + } + return _cache.SetAsync(key, value, options); + } + } + } +} diff --git a/src/Session/version.props b/src/Session/version.props new file mode 100644 index 0000000000..669c874829 --- /dev/null +++ b/src/Session/version.props @@ -0,0 +1,12 @@ + + + 2.1.1 + rtm + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix)-final + t000 + a- + $(FeatureBranchVersionPrefix)$(VersionSuffix)-$([System.Text.RegularExpressions.Regex]::Replace('$(FeatureBranchVersionSuffix)', '[^\w-]', '-')) + $(VersionSuffix)-$(BuildNumber) + +