From 7df56f6d2d9e749a00f6e1bd1ca5519096bd9eef Mon Sep 17 00:00:00 2001 From: Pranav K Date: Sat, 14 Feb 2015 09:50:08 -0800 Subject: [PATCH] Porting Session from Microsoft.Framework.Cache --- .gitattributes | 50 +++ .gitignore | 26 ++ CONTRIBUTING.md | 4 + Microsoft.AspNet.Session.sln | 45 +++ NuGet.Config | 7 + README.md | 9 + build.cmd | 28 ++ build.sh | 38 +++ global.json | 3 + makefile.shade | 7 + samples/SessionSample/SessionSample.kproj | 18 ++ samples/SessionSample/Startup.cs | 61 ++++ samples/SessionSample/project.json | 16 + .../CachingServicesExtensions.cs | 15 + .../DistributedSession.cs | 293 ++++++++++++++++++ .../DistributedSessionStore.cs | 40 +++ src/Microsoft.AspNet.Session/ISessionStore.cs | 15 + .../Microsoft.AspNet.Session.kproj | 17 + .../NotNullAttribute.cs | 12 + .../SessionDefaults.cs | 12 + .../SessionFactory.cs | 36 +++ .../SessionFeature.cs | 14 + .../SessionMiddleware.cs | 155 +++++++++ .../SessionMiddlewareExtensions.cs | 49 +++ .../SessionOptions.cs | 43 +++ src/Microsoft.AspNet.Session/SipHash.cs | 204 ++++++++++++ src/Microsoft.AspNet.Session/project.json | 21 ++ .../Microsoft.AspNet.Session.Tests.kproj | 17 + .../SessionTests.cs | 266 ++++++++++++++++ .../project.json | 14 + 30 files changed, 1535 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Microsoft.AspNet.Session.sln create mode 100644 NuGet.Config create mode 100644 README.md create mode 100644 build.cmd create mode 100644 build.sh create mode 100644 global.json create mode 100644 makefile.shade create mode 100644 samples/SessionSample/SessionSample.kproj create mode 100644 samples/SessionSample/Startup.cs create mode 100644 samples/SessionSample/project.json create mode 100644 src/Microsoft.AspNet.Session/CachingServicesExtensions.cs create mode 100644 src/Microsoft.AspNet.Session/DistributedSession.cs create mode 100644 src/Microsoft.AspNet.Session/DistributedSessionStore.cs create mode 100644 src/Microsoft.AspNet.Session/ISessionStore.cs create mode 100644 src/Microsoft.AspNet.Session/Microsoft.AspNet.Session.kproj create mode 100644 src/Microsoft.AspNet.Session/NotNullAttribute.cs create mode 100644 src/Microsoft.AspNet.Session/SessionDefaults.cs create mode 100644 src/Microsoft.AspNet.Session/SessionFactory.cs create mode 100644 src/Microsoft.AspNet.Session/SessionFeature.cs create mode 100644 src/Microsoft.AspNet.Session/SessionMiddleware.cs create mode 100644 src/Microsoft.AspNet.Session/SessionMiddlewareExtensions.cs create mode 100644 src/Microsoft.AspNet.Session/SessionOptions.cs create mode 100644 src/Microsoft.AspNet.Session/SipHash.cs create mode 100644 src/Microsoft.AspNet.Session/project.json create mode 100644 test/Microsoft.AspNet.Session.Tests/Microsoft.AspNet.Session.Tests.kproj create mode 100644 test/Microsoft.AspNet.Session.Tests/SessionTests.cs create mode 100644 test/Microsoft.AspNet.Session.Tests/project.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..bdaa5ba982 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,50 @@ +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain + +*.jpg binary +*.png binary +*.gif binary + +*.cs text=auto diff=csharp +*.vb text=auto +*.resx text=auto +*.c text=auto +*.cpp text=auto +*.cxx text=auto +*.h text=auto +*.hxx text=auto +*.py text=auto +*.rb text=auto +*.java text=auto +*.html text=auto +*.htm text=auto +*.css text=auto +*.scss text=auto +*.sass text=auto +*.less text=auto +*.js text=auto +*.lisp text=auto +*.clj text=auto +*.sql text=auto +*.php text=auto +*.lua text=auto +*.m text=auto +*.asm text=auto +*.erl text=auto +*.fs text=auto +*.fsx text=auto +*.hs text=auto + +*.csproj text=auto +*.vbproj text=auto +*.fsproj text=auto +*.dbproj text=auto +*.sln text=auto eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..216e8d9c58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +[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 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..eac4268e4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +Contributing +====== + +Information on contributing to this repo is in the [Contributing Guide](https://github.com/aspnet/Home/blob/master/CONTRIBUTING.md) in the Home repo. diff --git a/Microsoft.AspNet.Session.sln b/Microsoft.AspNet.Session.sln new file mode 100644 index 0000000000..11c3cf5f62 --- /dev/null +++ b/Microsoft.AspNet.Session.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.22604.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{E9D63F97-6078-42AD-BFD3-F956BF921BB5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A189F10C-3A9C-4F81-83D0-32E5FE50DAD8}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Session", "src\Microsoft.AspNet.Session\Microsoft.AspNet.Session.kproj", "{71802736-F640-4733-9671-02D267EDD76A}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.Session.Tests", "test\Microsoft.AspNet.Session.Tests\Microsoft.AspNet.Session.Tests.kproj", "{8C131A0A-BC1A-4CF3-8B77-8813FBFE5639}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{94E80ED2-9F27-40AC-A9EF-C707BDFAA3BE}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "SessionSample", "samples\SessionSample\SessionSample.kproj", "{FE0B9969-3BDE-4A7D-BE1B-47EAE8DBF365}" +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} + EndGlobalSection +EndGlobal diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000000..f41e9c631d --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000000..c3a8d80720 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +Caching +================ + +Contains libraries for caching for ASP.NET 5. + +This project is part of ASP.NET 5. You can find samples, documentation and getting started instructions for ASP.NET 5 at the [Home](https://github.com/aspnet/home) repo. + + + diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000000..86ca5bbbf1 --- /dev/null +++ b/build.cmd @@ -0,0 +1,28 @@ +@echo off +cd %~dp0 + +SETLOCAL +SET CACHED_NUGET=%LocalAppData%\NuGet\NuGet.exe + +IF EXIST %CACHED_NUGET% goto copynuget +echo Downloading latest version of NuGet.exe... +IF NOT EXIST %LocalAppData%\NuGet md %LocalAppData%\NuGet +@powershell -NoProfile -ExecutionPolicy unrestricted -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest 'https://www.nuget.org/nuget.exe' -OutFile '%CACHED_NUGET%'" + +:copynuget +IF EXIST .nuget\nuget.exe goto restore +md .nuget +copy %CACHED_NUGET% .nuget\nuget.exe > nul + +:restore +IF EXIST packages\KoreBuild goto run +.nuget\NuGet.exe install KoreBuild -ExcludeVersion -o packages -nocache -pre +.nuget\NuGet.exe install Sake -version 0.2 -o packages -ExcludeVersion + +IF "%SKIP_KRE_INSTALL%"=="1" goto run +CALL packages\KoreBuild\build\kvm upgrade -runtime CLR -x86 +CALL packages\KoreBuild\build\kvm install default -runtime CoreCLR -x86 + +:run +CALL packages\KoreBuild\build\kvm use default -runtime CLR -x86 +packages\Sake\tools\Sake.exe -I packages\KoreBuild\build -f makefile.shade %* diff --git a/build.sh b/build.sh new file mode 100644 index 0000000000..c7873ef58e --- /dev/null +++ b/build.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +if test `uname` = Darwin; then + cachedir=~/Library/Caches/KBuild +else + if [ -z $XDG_DATA_HOME ]; then + cachedir=$HOME/.local/share + else + cachedir=$XDG_DATA_HOME; + fi +fi +mkdir -p $cachedir + +url=https://www.nuget.org/nuget.exe + +if test ! -f $cachedir/nuget.exe; then + wget -O $cachedir/nuget.exe $url 2>/dev/null || curl -o $cachedir/nuget.exe --location $url /dev/null +fi + +if test ! -e .nuget; then + mkdir .nuget + cp $cachedir/nuget.exe .nuget/nuget.exe +fi + +if test ! -d packages/KoreBuild; then + mono .nuget/nuget.exe install KoreBuild -ExcludeVersion -o packages -nocache -pre + mono .nuget/nuget.exe install Sake -version 0.2 -o packages -ExcludeVersion +fi + +if ! type k > /dev/null 2>&1; then + source packages/KoreBuild/build/kvm.sh +fi + +if ! type k > /dev/null 2>&1; then + kvm upgrade +fi + +mono packages/Sake/tools/Sake.exe -I packages/KoreBuild/build -f makefile.shade "$@" diff --git a/global.json b/global.json new file mode 100644 index 0000000000..840c36f6ad --- /dev/null +++ b/global.json @@ -0,0 +1,3 @@ +{ + "sources": ["src"] +} \ No newline at end of file diff --git a/makefile.shade b/makefile.shade new file mode 100644 index 0000000000..562494d144 --- /dev/null +++ b/makefile.shade @@ -0,0 +1,7 @@ + +var VERSION='0.1' +var FULL_VERSION='0.1' +var AUTHORS='Microsoft Open Technologies, Inc.' + +use-standard-lifecycle +k-standard-goals diff --git a/samples/SessionSample/SessionSample.kproj b/samples/SessionSample/SessionSample.kproj new file mode 100644 index 0000000000..941cbc31f0 --- /dev/null +++ b/samples/SessionSample/SessionSample.kproj @@ -0,0 +1,18 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + fe0b9969-3bde-4a7d-be1b-47eae8dbf365 + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + 29562 + + + \ No newline at end of file diff --git a/samples/SessionSample/Startup.cs b/samples/SessionSample/Startup.cs new file mode 100644 index 0000000000..7c8716fff7 --- /dev/null +++ b/samples/SessionSample/Startup.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.Logging; +using Microsoft.Framework.Logging.Console; + +namespace SessionSample +{ + public class Startup + { + public Startup(ILoggerFactory loggerFactory) + { + loggerFactory.AddConsole(LogLevel.Verbose); + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddCachingServices(); + services.AddSessionServices(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseSession(o => { + o.IdleTimeout = TimeSpan.FromSeconds(30); }); + // app.UseInMemorySession(); + // app.UseDistributedSession(new RedisCache(new RedisCacheOptions() { Configuration = "localhost" })); + + app.Map("/session", subApp => + { + subApp.Run(async context => + { + int visits = 0; + visits = context.Session.GetInt("visits") ?? 0; + context.Session.SetInt("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.GetInt("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.SetInt("visits", ++visits); + await context.Response.WriteAsync("Your session was located, you've visited the site this many times: " + visits); + } + await context.Response.WriteAsync(""); + }); + } + } +} diff --git a/samples/SessionSample/project.json b/samples/SessionSample/project.json new file mode 100644 index 0000000000..4a82a27681 --- /dev/null +++ b/samples/SessionSample/project.json @@ -0,0 +1,16 @@ +{ + "webroot": "wwwroot", + "exclude": "wwwroot/**/*.*", + "dependencies": { + "Microsoft.AspNet.Http.Extensions": "1.0.0-*", + "Microsoft.AspNet.Server.IIS": "1.0.0-*", + "Microsoft.AspNet.Server.WebListener" : "1.0.0-*", + "Microsoft.AspNet.Session": "1.0.0-*", + "Microsoft.Framework.Cache.Redis": "1.0.0-*", + "Microsoft.Framework.Logging.Console": "1.0.0-*" + }, + "commands": { "web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:5001" }, + "frameworks" : { + "aspnet50" : { } + } +} diff --git a/src/Microsoft.AspNet.Session/CachingServicesExtensions.cs b/src/Microsoft.AspNet.Session/CachingServicesExtensions.cs new file mode 100644 index 0000000000..69ef155018 --- /dev/null +++ b/src/Microsoft.AspNet.Session/CachingServicesExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Session; + +namespace Microsoft.Framework.DependencyInjection +{ + public static class CachingServicesExtensions + { + public static IServiceCollection AddSessionServices(this IServiceCollection collection) + { + return collection.AddTransient(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/DistributedSession.cs b/src/Microsoft.AspNet.Session/DistributedSession.cs new file mode 100644 index 0000000000..f413245e14 --- /dev/null +++ b/src/Microsoft.AspNet.Session/DistributedSession.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.AspNet.Http.Interfaces; +using Microsoft.Framework.Cache.Distributed; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Session +{ + public class DistributedSession : ISession + { + private const byte SerializationRevision = 1; + private const int KeyLengthLimit = ushort.MaxValue; + + private readonly IDistributedCache _cache; + private readonly string _sessionId; + private readonly TimeSpan _idleTimeout; + private readonly Func _tryEstablishSession; + private readonly IDictionary _store; + private readonly ILogger _logger; + private bool _isModified; + private bool _loaded; + private bool _isNewSessionKey; + + public DistributedSession([NotNull] IDistributedCache cache, [NotNull] string sessionId, TimeSpan idleTimeout, + [NotNull] Func tryEstablishSession, [NotNull] ILoggerFactory loggerFactory, bool isNewSessionKey) + { + _cache = cache; + _sessionId = sessionId; + _idleTimeout = idleTimeout; + _tryEstablishSession = tryEstablishSession; + _store = new Dictionary(); + _logger = loggerFactory.Create(); + _isNewSessionKey = isNewSessionKey; + } + + public IEnumerable Keys + { + get + { + Load(); // TODO: Silent failure + return _store.Keys.Select(key => key.KeyString); + } + } + + public bool TryGetValue(string key, out byte[] value) + { + Load(); // TODO: Silent failure + return _store.TryGetValue(new EncodedKey(key), out value); + } + + public void Set(string key, ArraySegment value) + { + var encodedKey = new EncodedKey(key); + if (encodedKey.KeyBytes.Length > KeyLengthLimit) + { + throw new ArgumentOutOfRangeException("key", key, + string.Format("The key cannot be longer than '{0}' when encoded with UTF-8.", KeyLengthLimit)); + } + if (value.Array == null) + { + throw new ArgumentException("The ArraySegment.Array cannot be null.", "value"); + } + + Load(); + if (!_tryEstablishSession()) + { + throw new InvalidOperationException("The session cannot be established after the response has started."); + } + _isModified = true; + byte[] copy = new byte[value.Count]; + Buffer.BlockCopy(value.Array, value.Offset, copy, 0, value.Count); + _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(); + } + + // TODO: This should throw if called directly, but most other places it should fail silently (e.g. TryGetValue should just return null). + public void Load() + { + if (!_loaded) + { + Stream data; + if (_cache.TryGetValue(_sessionId, out data)) + { + Deserialize(data); + } + else if (!_isNewSessionKey) + { + _logger.WriteWarning("Accessing expired session {0}", _sessionId); + } + _loaded = true; + } + } + + public void Commit() + { + if (_isModified) + { + Stream data; + if (_logger.IsEnabled(LogLevel.Information) && !_cache.TryGetValue(_sessionId, out data)) + { + _logger.WriteInformation("Session {0} started", _sessionId); + } + _isModified = false; + _cache.Set(_sessionId, context => { + context.SetSlidingExpiration(_idleTimeout); + Serialize(context.Data); + }); + } + } + + // Format: + // Serialization revision: 1 byte, range 0-255 + // Entry count: 3 bytes, range 0-16,777,215 + // 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); + + 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) + { + // TODO: Throw? + // Replace the un-readable format. + _isModified = true; + return; + } + + int expectedEntries = DeserializeNumFrom3Bytes(content); + for (int i = 0; i < expectedEntries; i++) + { + int keyLength = DeserializeNumFrom2Bytes(content); + var key = new EncodedKey(content.ReadBytes(keyLength)); + int dataLength = DeserializeNumFrom4Bytes(content); + _store[key] = content.ReadBytes(dataLength); + } + } + + private void SerializeNumAs2Bytes(Stream output, int num) + { + if (num < 0 || ushort.MaxValue < num) + { + throw new ArgumentOutOfRangeException("num", num, "The value cannot be serialized in two bytes."); + } + 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("num", num, "The value cannot be serialized in three bytes."); + } + 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("num", num, "The value cannot be negative."); + } + 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(); + } + + // Keys are stored in their utf-8 encoded state. + // This saves us from de-serializing and re-serializing every key on every request. + private 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; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/DistributedSessionStore.cs b/src/Microsoft.AspNet.Session/DistributedSessionStore.cs new file mode 100644 index 0000000000..91b087a943 --- /dev/null +++ b/src/Microsoft.AspNet.Session/DistributedSessionStore.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Http.Interfaces; +using Microsoft.Framework.Cache.Distributed; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Session +{ + public class DistributedSessionStore : ISessionStore + { + private readonly IDistributedCache _cache; + private readonly ILoggerFactory _loggerFactory; + + public DistributedSessionStore([NotNull] IDistributedCache cache, [NotNull] ILoggerFactory loggerFactory) + { + _cache = cache; + _loggerFactory = loggerFactory; + } + + public bool IsAvailable + { + get + { + return true; // TODO: + } + } + + public void Connect() + { + _cache.Connect(); + } + + public ISession Create([NotNull] string sessionId, TimeSpan idleTimeout, [NotNull] Func tryEstablishSession, bool isNewSessionKey) + { + return new DistributedSession(_cache, sessionId, idleTimeout, tryEstablishSession, _loggerFactory, isNewSessionKey); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/ISessionStore.cs b/src/Microsoft.AspNet.Session/ISessionStore.cs new file mode 100644 index 0000000000..81cea1c04a --- /dev/null +++ b/src/Microsoft.AspNet.Session/ISessionStore.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Http.Interfaces; + +namespace Microsoft.AspNet.Session +{ + public interface ISessionStore + { + bool IsAvailable { get; } + void Connect(); + ISession Create(string sessionId, TimeSpan idleTimeout, Func tryEstablishSession, bool isNewSessionKey); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/Microsoft.AspNet.Session.kproj b/src/Microsoft.AspNet.Session/Microsoft.AspNet.Session.kproj new file mode 100644 index 0000000000..11c899ba40 --- /dev/null +++ b/src/Microsoft.AspNet.Session/Microsoft.AspNet.Session.kproj @@ -0,0 +1,17 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 71802736-f640-4733-9671-02d267edd76a + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + diff --git a/src/Microsoft.AspNet.Session/NotNullAttribute.cs b/src/Microsoft.AspNet.Session/NotNullAttribute.cs new file mode 100644 index 0000000000..a649da0de4 --- /dev/null +++ b/src/Microsoft.AspNet.Session/NotNullAttribute.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Session +{ + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + internal sealed class NotNullAttribute : Attribute + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionDefaults.cs b/src/Microsoft.AspNet.Session/SessionDefaults.cs new file mode 100644 index 0000000000..9f06ced3f4 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionDefaults.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Session +{ + public static class SessionDefaults + { + public static string CookieName = ".AspNet.Session"; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionFactory.cs b/src/Microsoft.AspNet.Session/SessionFactory.cs new file mode 100644 index 0000000000..3b9e6cc0ac --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionFactory.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Http.Interfaces; + +namespace Microsoft.AspNet.Session +{ + public class SessionFactory : ISessionFactory + { + private readonly string _sessionKey; + private readonly ISessionStore _store; + private readonly TimeSpan _idleTimeout; + private readonly Func _tryEstablishSession; + private readonly bool _isNewSessionKey; + + public SessionFactory([NotNull] string sessionKey, [NotNull] ISessionStore store, TimeSpan idleTimeout, [NotNull] Func tryEstablishSession, bool isNewSessionKey) + { + _sessionKey = sessionKey; + _store = store; + _idleTimeout = idleTimeout; + _tryEstablishSession = tryEstablishSession; + _isNewSessionKey = isNewSessionKey; + } + + public bool IsAvailable + { + get { return _store.IsAvailable; } + } + + public ISession Create() + { + return _store.Create(_sessionKey, _idleTimeout, _tryEstablishSession, _isNewSessionKey); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionFeature.cs b/src/Microsoft.AspNet.Session/SessionFeature.cs new file mode 100644 index 0000000000..3b78efb514 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionFeature.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Http.Interfaces; + +namespace Microsoft.AspNet.Session +{ + public class SessionFeature : ISessionFeature + { + public ISessionFactory Factory { get; set; } + + public ISession Session { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionMiddleware.cs b/src/Microsoft.AspNet.Session/SessionMiddleware.cs new file mode 100644 index 0000000000..47ae9793f9 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionMiddleware.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Interfaces; +using Microsoft.Framework.Logging; +using Microsoft.Framework.OptionsModel; + +namespace Microsoft.AspNet.Session +{ + 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; + + public SessionMiddleware( + [NotNull] RequestDelegate next, + [NotNull] ILoggerFactory loggerFactory, + [NotNull] IEnumerable sessionStore, + [NotNull] IOptions options, + [NotNull] ConfigureOptions configureOptions) + { + _next = next; + _logger = loggerFactory.Create(); + if (configureOptions != null) + { + _options = options.GetNamedOptions(configureOptions.Name); + configureOptions.Configure(_options); + } + else + { + _options = options.Options; + } + + if (_options.Store == null) + { + _options.Store = sessionStore.FirstOrDefault(); + if (_options.Store == null) + { + throw new ArgumentException("ISessionStore must be specified."); + } + } + + _options.Store.Connect(); + } + + public async Task Invoke(HttpContext context) + { + var isNewSessionKey = false; + Func tryEstablishSession = ReturnTrue; + var sessionKey = context.Request.Cookies.Get(_options.CookieName); + 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(); + var establisher = new SessionEstablisher(context, sessionKey, _options); + tryEstablishSession = establisher.TryEstablishSession; + isNewSessionKey = true; + } + + var feature = new SessionFeature(); + feature.Factory = new SessionFactory(sessionKey, _options.Store, _options.IdleTimeout, tryEstablishSession, isNewSessionKey); + feature.Session = feature.Factory.Create(); + context.SetFeature(feature); + + try + { + await _next(context); + } + finally + { + context.SetFeature(null); + + if (feature.Session != null) + { + try + { + feature.Session.Commit(); + } + catch (Exception ex) + { + _logger.WriteError("Error closing the session.", ex); + } + } + } + } + + private class SessionEstablisher + { + private readonly HttpContext _context; + private readonly string _sessionKey; + private readonly SessionOptions _options; + private bool _shouldEstablishSession; + + public SessionEstablisher(HttpContext context, string sessionKey, SessionOptions options) + { + _context = context; + _sessionKey = sessionKey; + _options = options; + context.Response.OnSendingHeaders(OnSendingHeadersCallback, state: this); + } + + private static void OnSendingHeadersCallback(object state) + { + var establisher = (SessionEstablisher)state; + if (establisher._shouldEstablishSession) + { + establisher.SetCookie(); + } + } + + private void SetCookie() + { + var cookieOptions = new CookieOptions + { + Domain = _options.CookieDomain, + HttpOnly = _options.CookieHttpOnly, + Path = _options.CookiePath ?? "/", + }; + + _context.Response.Cookies.Append(_options.CookieName, _sessionKey, cookieOptions); + + _context.Response.Headers.Set( + "Cache-Control", + "no-cache"); + + _context.Response.Headers.Set( + "Pragma", + "no-cache"); + + _context.Response.Headers.Set( + "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.HeadersSent); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionMiddlewareExtensions.cs b/src/Microsoft.AspNet.Session/SessionMiddlewareExtensions.cs new file mode 100644 index 0000000000..beb54727a1 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionMiddlewareExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Session; +using Microsoft.Framework.Cache.Distributed; +using Microsoft.Framework.Cache.Memory; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.Logging; +using Microsoft.Framework.OptionsModel; + +namespace Microsoft.AspNet.Builder +{ + public static class SessionMiddlewareExtensions + { + public static IServiceCollection ConfigureSession([NotNull] this IServiceCollection services, [NotNull] Action configure) + { + return services.ConfigureOptions(configure); + } + + public static IApplicationBuilder UseInMemorySession([NotNull] this IApplicationBuilder app, IMemoryCache cache = null, Action configure = null) + { + return app.UseDistributedSession(new LocalCache(cache ?? new MemoryCache(new MemoryCacheOptions())), configure); + } + + public static IApplicationBuilder UseDistributedSession([NotNull] this IApplicationBuilder app, + IDistributedCache cache, Action configure = null) + { + var loggerFactory = app.ApplicationServices.GetRequiredService(); + return app.UseSession(options => + { + options.Store = new DistributedSessionStore(cache, loggerFactory); + if (configure != null) + { + configure(options); + } + }); + } + + public static IApplicationBuilder UseSession([NotNull] this IApplicationBuilder app, Action configure = null) + { + return app.UseMiddleware( + new ConfigureOptions(configure ?? (o => { })) + { + Name = string.Empty + }); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SessionOptions.cs b/src/Microsoft.AspNet.Session/SessionOptions.cs new file mode 100644 index 0000000000..30c5948ed7 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SessionOptions.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Session +{ + public class SessionOptions + { + /// + /// Determines the cookie name used to persist the session ID. The default value is ".AspNet.Session". + /// + public string CookieName { get; set; } = SessionDefaults.CookieName; + + /// + /// Determines the domain used to create the cookie. Is not provided by default. + /// + public string CookieDomain { get; set; } + + /// + /// Determines the path used to create the cookie. The default value is "/" for highest browser compatibility. + /// + public string CookiePath { get; set; } = "/"; + + /// + /// 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. + /// + public bool CookieHttpOnly { get; set; } = true; + + /// + /// 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); + + /// + /// Gets or sets the session storage manager. This overrides any session store passed into the middleware constructor. + /// + public ISessionStore Store { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Session/SipHash.cs b/src/Microsoft.AspNet.Session/SipHash.cs new file mode 100644 index 0000000000..faadd44185 --- /dev/null +++ b/src/Microsoft.AspNet.Session/SipHash.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.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.AspNet.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/Microsoft.AspNet.Session/project.json b/src/Microsoft.AspNet.Session/project.json new file mode 100644 index 0000000000..ef37046585 --- /dev/null +++ b/src/Microsoft.AspNet.Session/project.json @@ -0,0 +1,21 @@ +{ + "version": "1.0.0-*", + "description": "ASP.NET 5 session state middleware.", + "dependencies": { + "Microsoft.AspNet.Http.Extensions": "1.0.0-*", + "Microsoft.AspNet.Http.Interfaces": "1.0.0-*", + "Microsoft.Framework.Cache.Distributed": "1.0.0-*", + "Microsoft.Framework.Logging": "1.0.0-*" + }, + "compilationOptions": { + "allowUnsafe": true + }, + "frameworks": { + "aspnet50": { }, + "aspnetcore50": { + "dependencies": { + "System.Security.Cryptography.RandomNumberGenerator": "4.0.0-beta-*" + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Session.Tests/Microsoft.AspNet.Session.Tests.kproj b/test/Microsoft.AspNet.Session.Tests/Microsoft.AspNet.Session.Tests.kproj new file mode 100644 index 0000000000..9c13767321 --- /dev/null +++ b/test/Microsoft.AspNet.Session.Tests/Microsoft.AspNet.Session.Tests.kproj @@ -0,0 +1,17 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 8c131a0a-bc1a-4cf3-8b77-8813fbfe5639 + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + + + diff --git a/test/Microsoft.AspNet.Session.Tests/SessionTests.cs b/test/Microsoft.AspNet.Session.Tests/SessionTests.cs new file mode 100644 index 0000000000..8b2108762c --- /dev/null +++ b/test/Microsoft.AspNet.Session.Tests/SessionTests.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.TestHost; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.Logging; +using Xunit; + +namespace Microsoft.AspNet.Session +{ + public class SessionTests + { + [Fact] + public async Task ReadingEmptySessionDoesNotCreateCookie() + { + using (var server = TestServer.Create(app => + { + app.UseServices(services => services.AddOptions()); + app.UseInMemorySession(); + app.Run(context => + { + Assert.Null(context.Session.GetString("NotFound")); + return Task.FromResult(0); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/"); + response.EnsureSuccessStatusCode(); + IEnumerable values; + Assert.False(response.Headers.TryGetValues("Set-Cookie", out values)); + } + } + + [Fact] + public async Task SettingAValueCausesTheCookieToBeCreated() + { + using (var server = TestServer.Create(app => + { + app.UseServices(services => services.AddOptions()); + app.UseInMemorySession(); + 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); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/"); + response.EnsureSuccessStatusCode(); + IEnumerable values; + Assert.True(response.Headers.TryGetValues("Set-Cookie", out values)); + Assert.Equal(1, values.Count()); + Assert.True(!string.IsNullOrWhiteSpace(values.First())); + } + } + + [Fact] + public async Task SessionCanBeAccessedOnTheNextRequest() + { + using (var server = TestServer.Create(app => + { + app.UseServices(services => services.AddOptions()); + app.UseInMemorySession(); + app.Run(context => + { + int? value = context.Session.GetInt("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + } + Assert.True(value.HasValue); + context.Session.SetInt("Key", value.Value + 1); + return context.Response.WriteAsync(value.Value.ToString()); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + client.DefaultRequestHeaders.Add("Cookie", response.Headers.GetValues("Set-Cookie")); + Assert.Equal("1", await client.GetStringAsync("/")); + Assert.Equal("2", await client.GetStringAsync("/")); + Assert.Equal("3", await client.GetStringAsync("/")); + } + } + + [Fact] + public async Task RemovedItemCannotBeAccessedAgain() + { + using (var server = TestServer.Create(app => + { + app.UseServices(services => services.AddOptions()); + app.UseInMemorySession(); + app.Run(context => + { + int? value = context.Session.GetInt("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + context.Session.SetInt("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()); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + client.DefaultRequestHeaders.Add("Cookie", response.Headers.GetValues("Set-Cookie")); + Assert.Equal("1", await client.GetStringAsync("/second")); + Assert.Equal("2", await client.GetStringAsync("/third")); + } + } + + [Fact] + public async Task ClearedItemsCannotBeAccessedAgain() + { + using (var server = TestServer.Create(app => + { + app.UseServices(services => services.AddOptions()); + app.UseInMemorySession(); + app.Run(context => + { + int? value = context.Session.GetInt("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 0; + context.Session.SetInt("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()); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/first"); + response.EnsureSuccessStatusCode(); + Assert.Equal("0", await response.Content.ReadAsStringAsync()); + + client = server.CreateClient(); + client.DefaultRequestHeaders.Add("Cookie", response.Headers.GetValues("Set-Cookie")); + Assert.Equal("1", await client.GetStringAsync("/second")); + Assert.Equal("2", await client.GetStringAsync("/third")); + } + } + + [Fact] + public async Task SessionStart_LogsInformation() + { + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + using (var server = TestServer.Create(app => + { + app.UseServices(services => + { + services.AddOptions(); + services.AddInstance(typeof(ILoggerFactory), loggerFactory); + }); + app.UseInMemorySession(); + app.Run(context => + { + context.Session.SetString("Key", "Value"); + return Task.FromResult(0); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/"); + response.EnsureSuccessStatusCode(); + Assert.Single(sink.Writes); + Assert.True(((ILoggerStructure)sink.Writes[0].State).Format().Contains("started")); + Assert.Equal(LogLevel.Information, sink.Writes[0].LogLevel); + } + } + + [Fact] + public async Task ExpiredSession_LogsWarning() + { + var sink = new TestSink(); + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + using (var server = TestServer.Create(app => + { + app.UseServices(services => + { + services.AddOptions(); + services.AddInstance(typeof(ILoggerFactory), loggerFactory); + }); + app.UseInMemorySession(configure: o => { + o.IdleTimeout = TimeSpan.FromMilliseconds(30); + }); + app.Run(context => + { + int? value = context.Session.GetInt("Key"); + if (context.Request.Path == new PathString("/first")) + { + Assert.False(value.HasValue); + value = 1; + context.Session.SetInt("Key", 1); + } + else if (context.Request.Path == new PathString("/second")) + { + Assert.False(value.HasValue); + value = 2; + } + return context.Response.WriteAsync(value.Value.ToString()); + }); + })) + { + var client = server.CreateClient(); + var response = await client.GetAsync("/first"); + response.EnsureSuccessStatusCode(); + + client = server.CreateClient(); + client.DefaultRequestHeaders.Add("Cookie", response.Headers.GetValues("Set-Cookie")); + Thread.Sleep(50); + Assert.Equal("2", await client.GetStringAsync("/second")); + Assert.Equal(2, sink.Writes.Count); + Assert.True(((ILoggerStructure)sink.Writes[0].State).Format().Contains("started")); + Assert.True(((ILoggerStructure)sink.Writes[1].State).Format().Contains("expired")); + Assert.Equal(LogLevel.Information, sink.Writes[0].LogLevel); + Assert.Equal(LogLevel.Warning, sink.Writes[1].LogLevel); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Session.Tests/project.json b/test/Microsoft.AspNet.Session.Tests/project.json new file mode 100644 index 0000000000..73b265653c --- /dev/null +++ b/test/Microsoft.AspNet.Session.Tests/project.json @@ -0,0 +1,14 @@ +{ + "dependencies": { + "Microsoft.AspNet.Session": "1.0.0-*", + "Microsoft.AspNet.RequestContainer": "1.0.0-*", + "Microsoft.AspNet.TestHost": "1.0.0-*", + "xunit.runner.kre": "1.0.0-*" + }, + "commands": { + "test": "xunit.runner.kre" + }, + "frameworks": { + "aspnet50": {} + } +} \ No newline at end of file