From 9ae0a5494bc335c3d940d730ae5d5f18c1018836 Mon Sep 17 00:00:00 2001 From: Jeff Doolittle Date: Fri, 28 Feb 2014 18:43:18 -0800 Subject: [PATCH] Token authentication and authorization implementation. --- .../Nancy.Authentication.Token.Tests.csproj | 113 +++++ .../Storage/FileSystemTokenKeyStoreFixture.cs | 55 +++ ...TokenAuthenticationConfigurationFixture.cs | 17 + .../TokenAuthenticationFixture.cs | 166 +++++++ .../TokenizerFixture.cs | 337 ++++++++++++++ .../app.config | 3 + .../packages.config | 6 + src/Nancy.Authentication.Token/ITokenizer.cs | 26 ++ .../Nancy.Authentication.Token.csproj | 97 ++++ .../Storage/FileSystemTokenKeyStore.cs | 113 +++++ .../Storage/ITokenKeyStore.cs | 28 ++ .../Storage/InMemoryTokenKeyStore.cs | 41 ++ .../TokenAuthentication.cs | 116 +++++ .../TokenAuthenticationConfiguration.cs | 29 ++ src/Nancy.Authentication.Token/Tokenizer.cs | 416 ++++++++++++++++++ .../nancy.authentication.token.nuspec | 26 ++ src/Nancy.Authentication.Token/readme.md | 90 ++++ .../LoginFixture.cs | 112 +++++ ...mo.Authentication.Token.TestingDemo.csproj | 109 +++++ .../TestBootstrapper.cs | 40 ++ .../packages.config | 5 + .../AuthModule.cs | 45 ++ .../DemoUserIdentity.cs | 11 + .../Nancy.Demo.Authentication.Token.csproj | 101 +++++ .../Program.cs | 23 + .../TokenAuthBootstrapper.cs | 27 ++ .../UserDatabase.cs | 34 ++ .../app.config | 3 + src/Nancy.sln | 60 +++ src/packages/repositories.config | 2 + 30 files changed, 2251 insertions(+) create mode 100644 src/Nancy.Authentication.Token.Tests/Nancy.Authentication.Token.Tests.csproj create mode 100644 src/Nancy.Authentication.Token.Tests/Storage/FileSystemTokenKeyStoreFixture.cs create mode 100644 src/Nancy.Authentication.Token.Tests/TokenAuthenticationConfigurationFixture.cs create mode 100644 src/Nancy.Authentication.Token.Tests/TokenAuthenticationFixture.cs create mode 100644 src/Nancy.Authentication.Token.Tests/TokenizerFixture.cs create mode 100644 src/Nancy.Authentication.Token.Tests/app.config create mode 100644 src/Nancy.Authentication.Token.Tests/packages.config create mode 100644 src/Nancy.Authentication.Token/ITokenizer.cs create mode 100644 src/Nancy.Authentication.Token/Nancy.Authentication.Token.csproj create mode 100644 src/Nancy.Authentication.Token/Storage/FileSystemTokenKeyStore.cs create mode 100644 src/Nancy.Authentication.Token/Storage/ITokenKeyStore.cs create mode 100644 src/Nancy.Authentication.Token/Storage/InMemoryTokenKeyStore.cs create mode 100644 src/Nancy.Authentication.Token/TokenAuthentication.cs create mode 100644 src/Nancy.Authentication.Token/TokenAuthenticationConfiguration.cs create mode 100644 src/Nancy.Authentication.Token/Tokenizer.cs create mode 100644 src/Nancy.Authentication.Token/nancy.authentication.token.nuspec create mode 100644 src/Nancy.Authentication.Token/readme.md create mode 100644 src/Nancy.Demo.Authentication.Token.TestingDemo/LoginFixture.cs create mode 100644 src/Nancy.Demo.Authentication.Token.TestingDemo/Nancy.Demo.Authentication.Token.TestingDemo.csproj create mode 100644 src/Nancy.Demo.Authentication.Token.TestingDemo/TestBootstrapper.cs create mode 100644 src/Nancy.Demo.Authentication.Token.TestingDemo/packages.config create mode 100644 src/Nancy.Demo.Authentication.Token/AuthModule.cs create mode 100644 src/Nancy.Demo.Authentication.Token/DemoUserIdentity.cs create mode 100644 src/Nancy.Demo.Authentication.Token/Nancy.Demo.Authentication.Token.csproj create mode 100644 src/Nancy.Demo.Authentication.Token/Program.cs create mode 100644 src/Nancy.Demo.Authentication.Token/TokenAuthBootstrapper.cs create mode 100644 src/Nancy.Demo.Authentication.Token/UserDatabase.cs create mode 100644 src/Nancy.Demo.Authentication.Token/app.config diff --git a/src/Nancy.Authentication.Token.Tests/Nancy.Authentication.Token.Tests.csproj b/src/Nancy.Authentication.Token.Tests/Nancy.Authentication.Token.Tests.csproj new file mode 100644 index 0000000000..61c9f61fb7 --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/Nancy.Authentication.Token.Tests.csproj @@ -0,0 +1,113 @@ + + + + + Debug + AnyCPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20} + Library + Properties + Nancy.Authentication.Token.Tests + Nancy.Authentication.Token.Tests + v4.0 + 512 + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + true + bin\MonoDebug\ + DEBUG;TRACE + full + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + bin\MonoRelease\ + TRACE + true + pdbonly + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + + False + ..\packages\FakeItEasy.1.14.0\lib\net40\FakeItEasy.dll + + + + + + + + + + False + ..\packages\xunit.1.9.1\lib\net20\xunit.dll + + + False + ..\packages\xunit.extensions.1.9.1\lib\net20\xunit.extensions.dll + + + + + Fakes\FakeRequest.cs + + + ShouldExtensions.cs + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + + + + {97fa024a-f6ed-4086-bcc1-1a51be63474c} + Nancy.Authentication.Token + + + {D79203C0-B672-4751-9C95-C3AB7D3FEFBE} + Nancy.Testing + + + {34576216-0dca-4b0f-a0dc-9075e75a676f} + Nancy + + + + + \ No newline at end of file diff --git a/src/Nancy.Authentication.Token.Tests/Storage/FileSystemTokenKeyStoreFixture.cs b/src/Nancy.Authentication.Token.Tests/Storage/FileSystemTokenKeyStoreFixture.cs new file mode 100644 index 0000000000..131209394d --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/Storage/FileSystemTokenKeyStoreFixture.cs @@ -0,0 +1,55 @@ +using System; + +namespace Nancy.Authentication.Token.Tests.Storage +{ + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text; + using Nancy.Authentication.Token.Storage; + using Nancy.Testing.Fakes; + using Xunit; + + public class FileSystemTokenKeyStoreFixture : IDisposable + { + private FileSystemTokenKeyStore keyStore; + + public FileSystemTokenKeyStoreFixture() + { + var rootPathProvider = new FakeRootPathProvider(); + this.keyStore = new FileSystemTokenKeyStore(rootPathProvider); + } + + [Fact] + public void Should_store_keys_in_file() + { + var keys = new Dictionary(); + + keys.Add(DateTime.UtcNow, Encoding.UTF8.GetBytes("fake encryption key")); + + keyStore.Store(keys); + + Assert.True(File.Exists(keyStore.FilePath)); + } + + [Fact] + public void Should_retrieve_keys_from_file() + { + var keys = new Dictionary(); + + keys.Add(DateTime.UtcNow, Encoding.UTF8.GetBytes("fake encryption key")); + + keyStore.Store(keys); + + var retrievedKeys = keyStore.Retrieve(); + + Assert.True(Encoding.UTF8.GetString(retrievedKeys.Values.First()) + == "fake encryption key"); + } + + public void Dispose() + { + keyStore.Purge(); + } + } +} diff --git a/src/Nancy.Authentication.Token.Tests/TokenAuthenticationConfigurationFixture.cs b/src/Nancy.Authentication.Token.Tests/TokenAuthenticationConfigurationFixture.cs new file mode 100644 index 0000000000..eab1fc55f4 --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/TokenAuthenticationConfigurationFixture.cs @@ -0,0 +1,17 @@ +namespace Nancy.Authentication.Token.Tests +{ + using System; + using Nancy.Tests; + using Xunit; + + public class TokenAuthenticationConfigurationFixture + { + [Fact] + public void Should_throw_with_null_tokenizer() + { + var result = Record.Exception(() => new TokenAuthenticationConfiguration(null)); + + result.ShouldBeOfType(typeof (ArgumentException)); + } + } +} diff --git a/src/Nancy.Authentication.Token.Tests/TokenAuthenticationFixture.cs b/src/Nancy.Authentication.Token.Tests/TokenAuthenticationFixture.cs new file mode 100644 index 0000000000..69c8ef638c --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/TokenAuthenticationFixture.cs @@ -0,0 +1,166 @@ +namespace Nancy.Authentication.Token.Tests +{ + using System; + using System.Collections.Generic; + using System.Threading; + using FakeItEasy; + + using Nancy.Security; + using Nancy.Tests; + using Nancy.Bootstrapper; + using Nancy.Tests.Fakes; + using Xunit; + + public class TokenAuthenticationFixture + { + private readonly TokenAuthenticationConfiguration config; + private readonly IPipelines hooks; + + public TokenAuthenticationFixture() + { + this.config = new TokenAuthenticationConfiguration(A.Fake()); + this.hooks = new Pipelines(); + TokenAuthentication.Enable(this.hooks, this.config); + } + + [Fact] + public void Should_add_a_pre_hook_in_application_when_enabled() + { + // Given + var pipelines = A.Fake(); + + // When + TokenAuthentication.Enable(pipelines, this.config); + + // Then + A.CallTo(() => pipelines.BeforeRequest.AddItemToStartOfPipeline(A>.Ignored)) + .MustHaveHappened(Repeated.Exactly.Once); + } + + [Fact] + public void Should_add_both_token_and_requires_auth_pre_hook_in_module_when_enabled() + { + // Given + var module = new FakeModule(); + + // When + TokenAuthentication.Enable(module, this.config); + + // Then + module.Before.PipelineDelegates.ShouldHaveCount(2); + } + + [Fact] + public void Should_throw_with_null_config_passed_to_enable_with_application() + { + // Given, When + var result = Record.Exception(() => TokenAuthentication.Enable(A.Fake(), null)); + + // Then + result.ShouldBeOfType(typeof(ArgumentNullException)); + } + + [Fact] + public void Should_throw_with_null_config_passed_to_enable_with_module() + { + // Given, When + var result = Record.Exception(() => TokenAuthentication.Enable(new FakeModule(), null)); + + // Then + result.ShouldBeOfType(typeof(ArgumentNullException)); + } + + [Fact] + public void Pre_request_hook_should_not_set_auth_details_with_no_auth_headers() + { + // Given + var context = new NancyContext() + { + Request = new FakeRequest("GET", "/") + }; + + // When + var result = this.hooks.BeforeRequest.Invoke(context, new CancellationToken()); + + // Then + result.Result.ShouldBeNull(); + context.CurrentUser.ShouldBeNull(); + } + + [Fact] + public void Pre_request_hook_should_not_set_auth_details_when_invalid_scheme_in_auth_header() + { + // Given + var context = CreateContextWithHeader( + "Authorization", new[] { "FooScheme" + " " + "A-FAKE-TOKEN" }); + + // When + var result = this.hooks.BeforeRequest.Invoke(context, new CancellationToken()); + + // Then + result.Result.ShouldBeNull(); + context.CurrentUser.ShouldBeNull(); + } + + [Fact] + public void Pre_request_hook_should_call_tokenizer_with_token_in_auth_header() + { + // Given + var context = CreateContextWithHeader( + "Authorization", new[] { "Token" + " " + "mytoken" }); + + // When + this.hooks.BeforeRequest.Invoke(context, new CancellationToken()); + + // Then + A.CallTo(() => config.Tokenizer.Detokenize("mytoken", context)).MustHaveHappened(); + } + + [Fact] + public void Should_set_user_in_context_with_valid_username_in_auth_header() + { + // Given + var fakePipelines = new Pipelines(); + + var context = CreateContextWithHeader( + "Authorization", new[] { "Token" + " " + "mytoken" }); + + var tokenizer = A.Fake(); + var fakeUser = A.Fake(); + A.CallTo(() => tokenizer.Detokenize("mytoken", context)).Returns(fakeUser); + + var cfg = new TokenAuthenticationConfiguration(tokenizer); + + TokenAuthentication.Enable(fakePipelines, cfg); + + // When + fakePipelines.BeforeRequest.Invoke(context, new CancellationToken()); + + // Then + context.CurrentUser.ShouldBeSameAs(fakeUser); + } + + private static NancyContext CreateContextWithHeader(string name, IEnumerable values) + { + var header = new Dictionary> + { + { name, values } + }; + + return new NancyContext() + { + Request = new FakeRequest("GET", "/", header) + }; + } + + class FakeModule : NancyModule + { + public FakeModule() + { + this.After = new AfterPipeline(); + this.Before = new BeforePipeline(); + this.OnError = new ErrorPipeline(); + } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token.Tests/TokenizerFixture.cs b/src/Nancy.Authentication.Token.Tests/TokenizerFixture.cs new file mode 100644 index 0000000000..1c2278781a --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/TokenizerFixture.cs @@ -0,0 +1,337 @@ +namespace Nancy.Authentication.Token.Tests +{ + using System; + using System.Collections.Generic; + using System.Text; + using System.Threading; + using Nancy.Authentication.Token.Storage; + using Nancy.Security; + using Nancy.Tests; + using Nancy.Tests.Fakes; + using FakeItEasy; + using Xunit; + + public class TokenizerFixture + { + private readonly NancyContext context; + private readonly FakeRequest request; + + public TokenizerFixture() + { + context = new NancyContext(); + request = new FakeRequest("GET", "/", + new Dictionary> + { + {"User-Agent", new[] {"a fake user agent"}} + }); + context.Request = request; + } + + [Fact] + public void Should_throw_argument_exception_if_token_expiration_exceeds_key_expiration() + { + var result = Record.Exception(() => + { + CreateTokenizer(cfg => cfg.TokenExpiration(() => TimeSpan.FromDays(8))); + }); + + result.ShouldBeOfType(); + } + + [Fact] + public void Should_throw_argument_exception_if_key_expiration_is_less_than_token_expiration() + { + var result = Record.Exception(() => + { + CreateTokenizer(cfg => cfg.KeyExpiration(() => TimeSpan.FromTicks(1))); + }); + + result.ShouldBeOfType(); + } + + [Fact] + public void Should_be_able_to_create_token_from_user_identity() + { + var tokenizer = CreateTokenizer(); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + token.ShouldNotBeNull(); + } + + [Fact] + public void Should_be_able_to_extract_user_identity_from_token() + { + var tokenizer = CreateTokenizer(); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + var detokenizedIdentity = tokenizer.Detokenize(token, context); + + detokenizedIdentity.ShouldNotBeNull(); + + detokenizedIdentity.UserName.ShouldEqual("joe"); + + detokenizedIdentity.Claims.ShouldEqualSequence(new[] { "claim1", "claim2" }); + } + + [Fact] + public void Should_not_be_able_to_extract_user_identity_from_modified_token() + { + var tokenizer = CreateTokenizer(); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + var parts = token.Split(new[] { ":" }, StringSplitOptions.RemoveEmptyEntries); + var bytes = Convert.FromBase64String(parts[0]); + + var tweak = new List(bytes); + tweak.Add(Encoding.UTF8.GetBytes("X")[0]); + + var badToken = Convert.ToBase64String(tweak.ToArray()) + ":" + parts[1]; + + var detokenizedIdentity = tokenizer.Detokenize(badToken, context); + + detokenizedIdentity.ShouldBeNull(); + } + + [Fact] + public void Should_be_able_to_extract_user_identity_from_token_with_extra_items() + { + var tokenizer = CreateTokenizer(); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + var detokenizedIdentity = tokenizer.Detokenize(token, context); + + detokenizedIdentity.ShouldNotBeNull(); + + detokenizedIdentity.UserName.ShouldEqual("joe"); + + detokenizedIdentity.Claims.ShouldEqualSequence(new[] { "claim1", "claim2" }); + } + + [Fact] + public void Should_fail_to_detokenize_when_additional_items_do_not_match() + { + var tokenizer = CreateTokenizer(); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + var badRequest = new FakeRequest("GET", "/", + new Dictionary> + { + {"User-Agent", new[] {"uh oh! no matchey!"}} + }); + var badContext = new NancyContext + { + Request = badRequest + }; + + var detokenizedIdentity = tokenizer.Detokenize(token, badContext); + + detokenizedIdentity.ShouldBeNull(); + } + + [Fact] + public void Should_expire_token_when_expiration_has_lapsed() + { + var tokenizer = CreateTokenizer(cfg => cfg.TokenExpiration(() => TimeSpan.FromMilliseconds(10))); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + Thread.Sleep(20); + + var detokenizedIdentity = tokenizer.Detokenize(token, context); + + detokenizedIdentity.ShouldBeNull(); + } + + [Fact] + public void Should_not_expire_token_when_key_expiration_has_lapsed_but_token_expiration_has_not() + { + var tokenizer = CreateTokenizer(cfg => + { + cfg.TokenExpiration(() => TimeSpan.FromMilliseconds(50)); + cfg.KeyExpiration(() => TimeSpan.FromMilliseconds(100)); + }); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + tokenizer.Tokenize(identity, context); // prime the pump to generate a key + + Thread.Sleep(75); // key is 75% to its expiration + + var token = tokenizer.Tokenize(identity, context); + + Thread.Sleep(25); // key is now expired but should not be purged until token expiration lapses + + var detokenizedIdentity = tokenizer.Detokenize(token, context); + + detokenizedIdentity.ShouldNotBeNull(); + } + + [Fact] + public void Should_generate_new_token_after_previous_key_has_expired() + { + var tokenizer = CreateTokenizer(cfg => + { + cfg.TokenExpiration(() => TimeSpan.FromMilliseconds(50)); + cfg.KeyExpiration(() => TimeSpan.FromMilliseconds(100)); + cfg.TokenStamp(() => new DateTime(2014, 1, 1)); + }); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); // prime the pump to generate a key + + Thread.Sleep(120); // expire the key + + var secondToken = tokenizer.Tokenize(identity, context); + + token.ShouldNotEqual(secondToken); + } + + [Fact] + public void Should_expire_token_when_key_expiration_has_lapsed() + { + var tokenizer = CreateTokenizer(cfg => + { + cfg.TokenExpiration(() => TimeSpan.FromMilliseconds(10)); + cfg.KeyExpiration(() => TimeSpan.FromMilliseconds(20)); + }); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + var token = tokenizer.Tokenize(identity, context); + + Thread.Sleep(30); + + var detokenizedIdentity = tokenizer.Detokenize(token, context); + + detokenizedIdentity.ShouldBeNull(); + } + + [Fact] + public void Should_retrieve_keys_from_store_when_tokenizer_is_created() + { + var keyCache = A.Fake(); + + CreateTokenizer(cfg => cfg.WithKeyCache(keyCache)); + + A.CallTo(() => keyCache.Retrieve()).MustHaveHappened(Repeated.Exactly.Once); + } + + [Fact] + public void Should_store_keys_when_the_first_token_is_tokenized() + { + var keyCache = A.Fake(); + + var tokenizer = CreateTokenizer(cfg => cfg.WithKeyCache(keyCache)); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + tokenizer.Tokenize(identity, this.context); + + A.CallTo(() => keyCache.Store(A>.Ignored)).MustHaveHappened(Repeated.Exactly.Once); + } + + [Fact] + public void Should_store_keys_when_a_key_is_purged() + { + var keyCache = A.Fake(); + + var tokenizer = CreateTokenizer(cfg => + { + cfg.TokenExpiration(() => TimeSpan.FromMilliseconds(1)); + cfg.KeyExpiration(() => TimeSpan.FromMilliseconds(2)); + cfg.WithKeyCache(keyCache); + }); + + var identity = new FakeUserIdentity + { + UserName = "joe", + Claims = new[] { "claim1", "claim2" } + }; + + tokenizer.Tokenize(identity, context); + + Thread.Sleep(5); + + tokenizer.Tokenize(identity, context); + + A.CallTo(() => keyCache.Store(A>.Ignored)).MustHaveHappened(Repeated.AtLeast.Once); + } + + private Tokenizer CreateTokenizer(Action configuration = null) + { + var tokenizer = new Tokenizer(cfg => + { + cfg.WithKeyCache(new InMemoryTokenKeyStore()); + + if (configuration != null) + { + configuration(cfg); + } + }); + return tokenizer; + } + + public class FakeUserIdentity : IUserIdentity + { + public string UserName { get; set; } + public IEnumerable Claims { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token.Tests/app.config b/src/Nancy.Authentication.Token.Tests/app.config new file mode 100644 index 0000000000..cb2586beb1 --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/app.config @@ -0,0 +1,3 @@ + + + diff --git a/src/Nancy.Authentication.Token.Tests/packages.config b/src/Nancy.Authentication.Token.Tests/packages.config new file mode 100644 index 0000000000..c86d91d1b2 --- /dev/null +++ b/src/Nancy.Authentication.Token.Tests/packages.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/ITokenizer.cs b/src/Nancy.Authentication.Token/ITokenizer.cs new file mode 100644 index 0000000000..b93215cf10 --- /dev/null +++ b/src/Nancy.Authentication.Token/ITokenizer.cs @@ -0,0 +1,26 @@ +namespace Nancy.Authentication.Token +{ + using Security; + + /// + /// Encodes and decodes authorization tokens. + /// + public interface ITokenizer + { + /// + /// Create a token from a + /// + /// The user identity from which to create a token. + /// NancyContext + /// The generated token. + string Tokenize(IUserIdentity userIdentity, NancyContext context); + + /// + /// Create a from a token + /// + /// The token from which to create a user identity. + /// NancyContext + /// The detokenized user identity. + IUserIdentity Detokenize(string token, NancyContext context); + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/Nancy.Authentication.Token.csproj b/src/Nancy.Authentication.Token/Nancy.Authentication.Token.csproj new file mode 100644 index 0000000000..9358fd0992 --- /dev/null +++ b/src/Nancy.Authentication.Token/Nancy.Authentication.Token.csproj @@ -0,0 +1,97 @@ + + + + + Debug + AnyCPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C} + Library + Properties + Nancy.Authentication.Token + Nancy.Authentication.Token + v4.0 + 512 + ..\ + true + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + bin\Debug\Nancy.Authentication.Token.XML + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + bin\Release\Nancy.Authentication.Token.XML + + + true + bin\MonoDebug\ + DEBUG;TRACE + full + AnyCPU + prompt + MinimumRecommendedRules.ruleset + bin\MonoDebug\Nancy.Authentication.Token.XML + + + bin\MonoRelease\ + TRACE + true + pdbonly + AnyCPU + prompt + MinimumRecommendedRules.ruleset + bin\MonoRelease\Nancy.Authentication.Token.XML + + + + + + + + + + + + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + + + {34576216-0dca-4b0f-a0dc-9075e75a676f} + Nancy + + + + + + + + + \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/Storage/FileSystemTokenKeyStore.cs b/src/Nancy.Authentication.Token/Storage/FileSystemTokenKeyStore.cs new file mode 100644 index 0000000000..ce40ae3ed1 --- /dev/null +++ b/src/Nancy.Authentication.Token/Storage/FileSystemTokenKeyStore.cs @@ -0,0 +1,113 @@ +namespace Nancy.Authentication.Token.Storage +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.Serialization.Formatters.Binary; + + /// + /// Stores encryption keys in the file system + /// + public class FileSystemTokenKeyStore : ITokenKeyStore + { + private IRootPathProvider rootPathProvider; + + private BinaryFormatter binaryFormatter; + + private static object syncLock = new object(); + + /// + /// Creates a new + /// + public FileSystemTokenKeyStore() + : this(new DefaultRootPathProvider()) + { + } + + /// + /// Creates a new + /// + /// + public FileSystemTokenKeyStore(IRootPathProvider rootPathProvider) + { + this.rootPathProvider = rootPathProvider; + this.binaryFormatter = new BinaryFormatter(); + } + + /// + /// Retrieves encryption keys. + /// + /// Keys + public IDictionary Retrieve() + { + lock (syncLock) + { + if (!File.Exists(FilePath)) + { + return new Dictionary(); + } + + using (var fileStream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + return (Dictionary)binaryFormatter.Deserialize(fileStream); + } + } + } + + /// + /// Stores encyrption keys. + /// + /// Keys + public void Store(IDictionary keys) + { + lock (syncLock) + { + if (!Directory.Exists(StorageLocation)) + { + Directory.CreateDirectory(StorageLocation); + } + + var keyChain = new Dictionary(keys); + + using (var fileStream = new FileStream(FilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None)) + { + binaryFormatter.Serialize(fileStream, keyChain); + } + } + } + + /// + /// Purges encryption keys + /// + public void Purge() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } + if (Directory.Exists(StorageLocation)) + { + Directory.Delete(StorageLocation); + } + } + + /// + /// The location where token keys are stored + /// + public string FilePath + { + get + { + return Path.Combine(StorageLocation, "keyChain.bin"); + } + } + + private string StorageLocation + { + get + { + return Path.Combine(rootPathProvider.GetRootPath(), "keyStore"); + } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/Storage/ITokenKeyStore.cs b/src/Nancy.Authentication.Token/Storage/ITokenKeyStore.cs new file mode 100644 index 0000000000..1cb92e4f2c --- /dev/null +++ b/src/Nancy.Authentication.Token/Storage/ITokenKeyStore.cs @@ -0,0 +1,28 @@ +namespace Nancy.Authentication.Token.Storage +{ + using System; + using System.Collections.Generic; + + /// + /// Stores and retrieves encryption keys + /// + public interface ITokenKeyStore + { + /// + /// Retrieves encryption keys + /// + /// Keys + IDictionary Retrieve(); + + /// + /// Stores encryption keys + /// + /// Keys + void Store(IDictionary keys); + + /// + /// Purges encryption keys + /// + void Purge(); + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/Storage/InMemoryTokenKeyStore.cs b/src/Nancy.Authentication.Token/Storage/InMemoryTokenKeyStore.cs new file mode 100644 index 0000000000..170eaa0226 --- /dev/null +++ b/src/Nancy.Authentication.Token/Storage/InMemoryTokenKeyStore.cs @@ -0,0 +1,41 @@ +namespace Nancy.Authentication.Token.Storage +{ + using System; + using System.Collections.Generic; + + /// + /// In in memory implementation of . Useful for testing or scenarios + /// where encryption keys do not need to persist across application restarts (due to updates, app pool + /// expiration, etc.) + /// + public class InMemoryTokenKeyStore : ITokenKeyStore + { + private IDictionary keys; + + /// + /// Retrieves encryption keys + /// + /// Keys + public IDictionary Retrieve() + { + return new Dictionary(this.keys ?? new Dictionary()); + } + + /// + /// Stores encryption keys + /// + /// Keys + public void Store(IDictionary keys) + { + this.keys = new Dictionary(keys); + } + + /// + /// Purges encryption keys + /// + public void Purge() + { + this.keys = new Dictionary(); + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/TokenAuthentication.cs b/src/Nancy.Authentication.Token/TokenAuthentication.cs new file mode 100644 index 0000000000..ed9e3a713f --- /dev/null +++ b/src/Nancy.Authentication.Token/TokenAuthentication.cs @@ -0,0 +1,116 @@ +namespace Nancy.Authentication.Token +{ + using System; + using Nancy.Bootstrapper; + using Nancy.Security; + + /// + /// Nancy Token authentication implementation + /// + public static class TokenAuthentication + { + private const string Scheme = "Token"; + + /// + /// Enables Token authentication for the application + /// + /// Pipelines to add handlers to (usually "this") + /// Forms authentication configuration + public static void Enable(IPipelines pipelines, TokenAuthenticationConfiguration configuration) + { + if (pipelines == null) + { + throw new ArgumentNullException("pipelines"); + } + + if (configuration == null) + { + throw new ArgumentNullException("configuration"); + } + + pipelines.BeforeRequest.AddItemToStartOfPipeline(GetCredentialRetrievalHook(configuration)); + } + + /// + /// Enables Token authentication for a module + /// + /// Module to add handlers to (usually "this") + /// Forms authentication configuration + public static void Enable(INancyModule module, TokenAuthenticationConfiguration configuration) + { + if (module == null) + { + throw new ArgumentNullException("module"); + } + + if (configuration == null) + { + throw new ArgumentNullException("configuration"); + } + + module.RequiresAuthentication(); + module.Before.AddItemToStartOfPipeline(GetCredentialRetrievalHook(configuration)); + } + + /// + /// Gets the pre request hook for loading the authenticated user's details + /// from the auth header. + /// + /// Token authentication configuration to use + /// Pre request hook delegate + private static Func GetCredentialRetrievalHook(TokenAuthenticationConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException("configuration"); + } + + return context => + { + RetrieveCredentials(context, configuration); + return null; + }; + } + + private static void RetrieveCredentials(NancyContext context, TokenAuthenticationConfiguration configuration) + { + var token = ExtractTokenFromHeader(context.Request); + + if (token != null) + { + var user = configuration.Tokenizer.Detokenize(token, context); + + if (user != null) + { + context.CurrentUser = user; + } + } + } + + private static string ExtractTokenFromHeader(Request request) + { + var authorization = + request.Headers.Authorization; + + if (string.IsNullOrEmpty(authorization)) + { + return null; + } + + if (!authorization.StartsWith(Scheme)) + { + return null; + } + + try + { + var encodedToken = authorization.Substring(Scheme.Length).Trim(); + return String.IsNullOrWhiteSpace(encodedToken) ? null : encodedToken; + } + catch (FormatException) + { + return null; + } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/TokenAuthenticationConfiguration.cs b/src/Nancy.Authentication.Token/TokenAuthenticationConfiguration.cs new file mode 100644 index 0000000000..1a613fa58d --- /dev/null +++ b/src/Nancy.Authentication.Token/TokenAuthenticationConfiguration.cs @@ -0,0 +1,29 @@ +namespace Nancy.Authentication.Token +{ + using System; + + /// + /// Configuration options for token authentication + /// + public class TokenAuthenticationConfiguration + { + /// + /// Initializes a new instance of the class. + /// + /// A valid instance of class + public TokenAuthenticationConfiguration(ITokenizer tokenizer) + { + if (tokenizer == null) + { + throw new ArgumentNullException("tokenizer"); + } + + this.Tokenizer = tokenizer; + } + + /// + /// Gets the token validator + /// + public ITokenizer Tokenizer { get; private set; } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/Tokenizer.cs b/src/Nancy.Authentication.Token/Tokenizer.cs new file mode 100644 index 0000000000..53cb9894dd --- /dev/null +++ b/src/Nancy.Authentication.Token/Tokenizer.cs @@ -0,0 +1,416 @@ +namespace Nancy.Authentication.Token +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Security.Cryptography; + using System.Text; + + using Nancy.Authentication.Token.Storage; + using Nancy.Security; + using Nancy.ErrorHandling; + + /// + /// Default implementation of + /// + public class Tokenizer : ITokenizer + { + private readonly TokenValidator validator; + private readonly TokenKeyRing keyRing; + private ITokenKeyStore keyStore = new FileSystemTokenKeyStore(); + private Encoding encoding = Encoding.UTF8; + private string claimsDelimiter = "|"; + private string hashDelimiter = ":"; + private string itemDelimiter = Environment.NewLine; + private Func tokenStamp = () => DateTime.UtcNow; + private Func now = () => DateTime.UtcNow; + private Func tokenExpiration = () => TimeSpan.FromDays(1); + private Func keyExpiration = () => TimeSpan.FromDays(7); + + private Func[] additionalItems = new Func[] + { + ctx => ctx.Request.Headers.UserAgent + }; + + /// + /// Initializes a new instance of the class. + /// + public Tokenizer() + : this(null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The configuration that should be used by the tokenizer. + public Tokenizer(Action configuration) + { + if (configuration != null) + { + var configurator = new TokenizerConfigurator(this); + configuration.Invoke(configurator); + } + this.keyRing = new TokenKeyRing(this); + this.validator = new TokenValidator(this.keyRing); + } + + /// + /// Creates a token from a . + /// + /// The user identity from which to create a token. + /// NancyContext + /// The generated token. + public string Tokenize(IUserIdentity userIdentity, NancyContext context) + { + var items = new List(); + items.Add(userIdentity.UserName); + items.Add(string.Join(this.claimsDelimiter, userIdentity.Claims)); + items.Add(this.tokenStamp().Ticks.ToString()); + + foreach (var item in this.additionalItems.Select(additionalItem => additionalItem(context))) + { + if (string.IsNullOrWhiteSpace(item)) + { + throw new RouteExecutionEarlyExitException(new Response { StatusCode = HttpStatusCode.Unauthorized }); + } + items.Add(item); + } + + var message = string.Join(this.itemDelimiter, items); + var token = CreateToken(message); + return token; + } + + /// + /// Creates a from a token. + /// + /// The token from which to create a user identity. + /// NancyContext + /// The detokenized user identity. + public IUserIdentity Detokenize(string token, NancyContext context) + { + var tokenComponents = token.Split(new[] { this.hashDelimiter }, StringSplitOptions.None); + if (tokenComponents.Length != 2) + { + return null; + } + + var messagebytes = Convert.FromBase64String(tokenComponents[0]); + var hash = Convert.FromBase64String(tokenComponents[1]); + + if (!this.validator.IsValid(messagebytes, hash)) + { + return null; + } + + var items = this.encoding.GetString(messagebytes).Split(new[] { this.itemDelimiter }, StringSplitOptions.None); + + var additionalItemCount = additionalItems.Count(); + for (var i = 0; i < additionalItemCount; i++) + { + var tokenizedValue = items[i + 3]; + var currentValue = additionalItems.ElementAt(i)(context); + if (tokenizedValue != currentValue) + { + // todo: may need to log here as this probably indicates hacking + return null; + } + } + + var generatedOn = new DateTime(long.Parse(items[2])); + + if (tokenStamp() - generatedOn > tokenExpiration()) + { + return null; + } + + var userName = items[0]; + var claims = items[1].Split(new[] { this.claimsDelimiter }, StringSplitOptions.None); + + return new TokenUserIdentity(userName, claims); + } + + private string CreateToken(string message) + { + var messagebytes = this.encoding.GetBytes(message); + var hash = this.validator.CreateHash(messagebytes); + return Convert.ToBase64String(messagebytes) + this.hashDelimiter + Convert.ToBase64String(hash); + } + + /// + /// Provides an API for configuring a instance. + /// + public class TokenizerConfigurator + { + private readonly Tokenizer tokenizer; + + /// + /// Initializes a new instance of the class. + /// + /// + public TokenizerConfigurator(Tokenizer tokenizer) + { + this.tokenizer = tokenizer; + } + + /// + /// Sets the token key store ued by the tokenizer + /// + /// + /// A reference to the current + public TokenizerConfigurator WithKeyCache(ITokenKeyStore store) + { + this.tokenizer.keyStore = store; + return this; + } + + /// + /// Sets the encoding used by the tokenizer + /// + /// + /// A reference to the current + public TokenizerConfigurator Encoding(Encoding encoding) + { + this.tokenizer.encoding = encoding; + return this; + } + + /// + /// Sets the delimiter between document and its hash + /// + /// + /// A reference to the current + public TokenizerConfigurator HashDelimiter(string hashDelimiter) + { + this.tokenizer.hashDelimiter = hashDelimiter; + return this; + } + + /// + /// Sets the delimiter between each item to be tokenized + /// + /// + /// A reference to the current + public TokenizerConfigurator ItemDelimiter(string itemDelimiter) + { + this.tokenizer.itemDelimiter = itemDelimiter; + return this; + } + + /// + /// Sets the delimiter between each claim + /// + /// + /// A reference to the current + public TokenizerConfigurator ClaimsDelimiter(string claimsDelimiter) + { + this.tokenizer.claimsDelimiter = claimsDelimiter; + return this; + } + + /// + /// Sets the token expiration interval. An expired token will cause a user to become unauthorized (logged out). + /// Suggested value is 1 day (which is also the default). + /// + /// + /// A reference to the current + public TokenizerConfigurator TokenExpiration(Func expiration) + { + this.tokenizer.tokenExpiration = expiration; + + if (this.tokenizer.tokenExpiration() >= this.tokenizer.keyExpiration()) + { + throw new ArgumentException("Token expiration must be less than key expiration", "expiration"); + } + + return this; + } + + /// + /// Sets the key expiration interval. Must be longer than the value. + /// When keys expire, they are scheduled to purge once any tokens they have been used to generate have expired. + /// Suggested range is 2 to 14 days. The default is 7 days. + /// + /// + /// A reference to the current + public TokenizerConfigurator KeyExpiration(Func expiration) + { + this.tokenizer.keyExpiration = expiration; + + if (this.tokenizer.tokenExpiration() >= this.tokenizer.keyExpiration()) + { + throw new ArgumentException("Key expiration must be greater than token expiration", "expiration"); + } + + return this; + } + + /// + /// Sets the token-generated-at timestamp + /// + /// + /// A reference to the current + public TokenizerConfigurator TokenStamp(Func tokenStamp) + { + this.tokenizer.tokenStamp = tokenStamp; + return this; + } + + /// + /// Sets the current date/time. + /// + /// + /// A reference to the current + public TokenizerConfigurator Now(Func now) + { + this.tokenizer.now = now; + return this; + } + + /// + /// Sets any additional items to be included when tokenizing. The default includes Request.Headers.UserAgent. + /// + /// + /// A reference to the current + public TokenizerConfigurator AdditionalItems(params Func[] additionalItems) + { + this.tokenizer.additionalItems = additionalItems; + return this; + } + } + + private class TokenUserIdentity : IUserIdentity + { + public TokenUserIdentity(string userName, IEnumerable claims) + { + UserName = userName; + Claims = claims; + } + + public string UserName { get; private set; } + public IEnumerable Claims { get; private set; } + } + + private class TokenValidator + { + private readonly TokenKeyRing keyRing; + + internal TokenValidator(TokenKeyRing keyRing) + { + this.keyRing = keyRing; + } + + public bool IsValid(byte[] message, byte[] hash) + { + return this.keyRing + .AllKeys() + .Select(key => GenerateHash(key, message)) + .Any(hash.SequenceEqual); + } + + public byte[] CreateHash(byte[] message) + { + var key = this.keyRing + .NonExpiredKeys() + .First(); + + return GenerateHash(key, message); + } + + private byte[] GenerateHash(byte[] key, byte[] message) + { + using (var hmac = new HMACSHA256(key)) + { + return hmac.ComputeHash(message); + } + } + } + + private class TokenKeyRing + { + private readonly Tokenizer tokenizer; + + private readonly IDictionary keys; + + internal TokenKeyRing(Tokenizer tokenizer) + { + this.tokenizer = tokenizer; + keys = this.tokenizer.keyStore.Retrieve(); + } + + public IEnumerable AllKeys() + { + return this.Keys(true); + } + + public IEnumerable NonExpiredKeys() + { + return this.Keys(false); + } + + private IEnumerable Keys(bool includeExpired) + { + var entriesToPurge = new List(); + var validKeys = new List(); + + foreach (var entry in this.keys.OrderByDescending(x => x.Key)) + { + if (IsReadyToPurge(entry)) + { + entriesToPurge.Add(entry.Key); + } + else if (!IsExpired(entry) || includeExpired) + { + validKeys.Add(entry.Value); + } + } + + var shouldStore = false; + + foreach (var entry in entriesToPurge) + { + this.keys.Remove(entry); + shouldStore = true; + } + + if (validKeys.Count == 0) + { + var key = CreateKey(); + this.keys[this.tokenizer.now()] = key; + validKeys.Add(key); + shouldStore = true; + } + + if (shouldStore) + { + this.tokenizer.keyStore.Store(keys); + } + + return validKeys; + } + + private bool IsReadyToPurge(KeyValuePair entry) + { + return this.tokenizer.now() - entry.Key > (this.tokenizer.keyExpiration() + this.tokenizer.tokenExpiration()); + } + + private bool IsExpired(KeyValuePair entry) + { + return this.tokenizer.now() - entry.Key > this.tokenizer.keyExpiration(); + } + + private byte[] CreateKey() + { + var secretKey = new byte[64]; + + using (var rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(secretKey); + } + + return secretKey; + } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/nancy.authentication.token.nuspec b/src/Nancy.Authentication.Token/nancy.authentication.token.nuspec new file mode 100644 index 0000000000..4547f661ec --- /dev/null +++ b/src/Nancy.Authentication.Token/nancy.authentication.token.nuspec @@ -0,0 +1,26 @@ + + + + Nancy.Authentication.Token + 0.0.0 + Andreas Håkansson, Steven Robbins and contributors + false + A token based authentication provider for Nancy. + Nancy is a lightweight web framework for the .Net platform, inspired by Sinatra. Nancy aim at delivering a low ceremony approach to building light, fast web applications. + en-US + Andreas Håkansson, Steven Robbins and contributors + http://nancyfx.org/nancy-nuget.png + https://github.com/NancyFx/Nancy/blob/master/license.txt + http://nancyfx.org + + + + Nancy Token Authentication + + + + + + + + \ No newline at end of file diff --git a/src/Nancy.Authentication.Token/readme.md b/src/Nancy.Authentication.Token/readme.md new file mode 100644 index 0000000000..ba01221fc8 --- /dev/null +++ b/src/Nancy.Authentication.Token/readme.md @@ -0,0 +1,90 @@ +# Meet Nancy Token Authentication + +The Nancy.Authentication.Token project was built for use by heterogeneous clients (iOS apps, Android apps, Angular SPA apps, etc.) that all communicate with the same back-end Nancy application. + +## Rationale + +Token authentication and authorization was built with the following requirements: + +* No Cookies (since not all client apps are web browsers) +* Avoid retrieving users and permissions from a backend data store once the user has been authenticated/authorized +* Allow client apps to store a token containing the current user's credentials for resubmission on subsequent requests (after first authenticating) +* Prevent rogue clients from simply generating their own spoofed credentials by incorporating a one-way hashing algorithm that ensures the token has not been tampered with +* Use server side keys for token hash generation with a configurable key expiration interval +* Use file system storage of server-side token generation private keys to allow keys to survive an application restart or an app pool recycle. Note: an "in memory" option is available primarily for testing, but could be used in a situation where expiring all user sessions on an application restart is acceptable behavior. + +Token Authentication can be wired up in a simliar fashion to other available forms of Nancy authentication. + +```csharp + public class Bootstrapper : DefaultNancyBootstrapper + { + protected override void RequestStartup(TinyIoCContainer container, IPipelines pipelines, NancyContext context) + { + TokenAuthentication.Enable(pipelines, new TokenAuthenticationConfiguration(container.Resolve())); + } + } +``` + +You will need to provide your own form of initial user authentication. This can use your own custom implementation that queries +from a database, from an AD store, from a webservice, or any other form you choose. It could also use another form of Nancy authentication (Basic with an IUserValidator implementation +for example). + +Tokens are generated from an `IUserIdentity` and a `NancyContext` by an implementation of `ITokenizer`. The +default implementation is named `Tokenizer` and provides some configuration options. By default, it generates a token +that includes the following components: + +* User name +* Pipe separated list of user claims +* UTC now in ticks +* The client's "User-Agent" http header value (required) + +It is recommended that you configure the Tokenizer to use an additional piece of information that can uniquely identify +the client device. + +The following code shows an example of how you can perform the initial user authorization and return the generated token to the client. + +```csharp + public class AuthModule : NancyModule + { + public AuthModule(ITokenizer tokenizer) + : base("/auth") + { + Post["/"] = x => + { + var userName = (string)this.Request.Form.UserName; + var password = (string)this.Request.Form.Password; + + var userIdentity = UserDatabase.ValidateUser(userName, password); + + if (userIdentity == null) + { + return HttpStatusCode.Unauthorized; + } + + var token = tokenizer.Tokenize(userIdentity, Context); + + return new + { + Token = token, + }; + }; + + Get["/validation"] = _ => + { + this.RequiresAuthentication(); + return "Yay! You are authenticated!"; + }; + + Get["/admin"] = _ => + { + this.RequiresAuthentication(); + this.RequiresClaims(new[] { "admin" }); + return "Yay! You are authorized!"; + }; + } + } +``` + +## Contributors + +Nancy.Authentication.Token was originally created by the crack development team at [Lotpath](http://lotpath.com) ([Lotpath on github](http://github.com/Lotpath)). diff --git a/src/Nancy.Demo.Authentication.Token.TestingDemo/LoginFixture.cs b/src/Nancy.Demo.Authentication.Token.TestingDemo/LoginFixture.cs new file mode 100644 index 0000000000..edb31ed1d5 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token.TestingDemo/LoginFixture.cs @@ -0,0 +1,112 @@ +namespace Nancy.Demo.Authentication.Token.TestingDemo +{ + using System; + using Nancy.Testing; + using Nancy.Tests; + using Xunit; + + public class LoginFixture + { + private readonly Browser browser; + + public LoginFixture() + { + var bootstrapper = new TestBootstrapper(); + this.browser = new Browser(bootstrapper); + } + + [Fact] + public void Should_return_generated_token_for_valid_user_credentials() + { + // Given, When + var response = this.browser.Post("/auth/", (with) => + { + with.HttpRequest(); + with.Accept("application/json"); + with.Header("User-Agent", "Nancy Browser"); + with.FormValue("UserName", "demo"); + with.FormValue("Password", "demo"); + }); + + // Then + response.Body.DeserializeJson().ShouldNotBeNull(); + } + + [Fact] + public void Should_return_unauthorized_for_invalid_user_credentials() + { + // Given, When + var response = this.browser.Post("/auth/", (with) => + { + with.HttpRequest(); + with.Accept("application/json"); + with.Header("User-Agent", "Nancy Browser"); + with.FormValue("UserName", "bad"); + with.FormValue("Password", "boy"); + }); + + // Then + response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + } + + [Fact] + public void Should_return_unauthorized_when_not_authenticated() + { + // Given, When + var response = this.browser.Get("/auth/validation/", (with) => + { + with.HttpRequest(); + }); + + // Then + response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + } + + [Fact] + public void Should_return_forbidden_when_not_authorized() + { + // Given, When + var response = this.browser.Post("/auth/", (with) => + { + with.HttpRequest(); + with.Accept("application/json"); + with.Header("User-Agent", "Nancy Browser"); + with.FormValue("UserName", "nonadmin"); + with.FormValue("Password", "nonadmin"); + }); + + var token = response.Body.DeserializeJson().Token; + + var secondResponse = response.Then.Get("/auth/admin/", with => + { + with.HttpRequest(); + with.Header("User-Agent", "Nancy Browser"); + with.Header("Authorization", "Token " + token); + }); + + // Then + secondResponse.StatusCode.ShouldEqual(HttpStatusCode.Forbidden); + } + + [Fact] + public void Should_return_unauthorized_without_user_agent() + { + // Given, When + var response = this.browser.Post("/auth/", (with) => + { + with.HttpRequest(); + with.Accept("application/json"); + with.FormValue("UserName", "demo"); + with.FormValue("Password", "demo"); + }); + + // Then + response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized); + } + + public class AuthResponse + { + public string Token { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token.TestingDemo/Nancy.Demo.Authentication.Token.TestingDemo.csproj b/src/Nancy.Demo.Authentication.Token.TestingDemo/Nancy.Demo.Authentication.Token.TestingDemo.csproj new file mode 100644 index 0000000000..2c4d9529c9 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token.TestingDemo/Nancy.Demo.Authentication.Token.TestingDemo.csproj @@ -0,0 +1,109 @@ + + + + + Debug + AnyCPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9} + Library + Properties + Nancy.Demo.Authentication.Token.TestingDemo + Nancy.Demo.Authentication.Token.TestingDemo + v4.0 + 512 + + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + true + bin\ + DEBUG;TRACE + full + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + bin\ + TRACE + true + pdbonly + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + + + + + + + + + ..\packages\xunit.1.9.1\lib\net20\xunit.dll + + + ..\packages\xunit.extensions.1.9.1\lib\net20\xunit.extensions.dll + + + + + ShouldExtensions.cs + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + {97FA024A-F6ED-4086-BCC1-1A51BE63474C} + Nancy.Authentication.Token + + + {35460aa4-b94a-4b64-9418-7243ec3d2f01} + Nancy.Demo.Authentication.Token + + + {d79203c0-b672-4751-9c95-c3ab7d3fefbe} + Nancy.Testing + + + {2c6f51df-015c-4db6-b44c-0e5e4f25e2a9} + Nancy.ViewEngines.Razor + + + {34576216-0dca-4b0f-a0dc-9075e75a676f} + Nancy + + + + + \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token.TestingDemo/TestBootstrapper.cs b/src/Nancy.Demo.Authentication.Token.TestingDemo/TestBootstrapper.cs new file mode 100644 index 0000000000..676b9619b4 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token.TestingDemo/TestBootstrapper.cs @@ -0,0 +1,40 @@ +namespace Nancy.Demo.Authentication.Token.TestingDemo +{ + using System; + using System.IO; + using Nancy.Authentication.Token; + using Nancy.Authentication.Token.Storage; + using Nancy.Demo.Authentication.Token; + using Nancy.Testing.Fakes; + using Nancy.Testing; + + public class TestBootstrapper : TokenAuthBootstrapper + { + protected override void ConfigureApplicationContainer(TinyIoc.TinyIoCContainer container) + { + container.Register(new Tokenizer(cfg => cfg.WithKeyCache(new InMemoryTokenKeyStore()))); + } + + protected override IRootPathProvider RootPathProvider + { + get + { + var assemblyFilePath = + new Uri(typeof(TokenAuthBootstrapper).Assembly.CodeBase).LocalPath; + + var assemblyPath = + Path.GetDirectoryName(assemblyFilePath); + + var rootPath = + PathHelper.GetParent(assemblyPath, 2); + + rootPath = + Path.Combine(rootPath, @"Nancy.Demo.Authentication.Token"); + + FakeRootPathProvider.RootPath = rootPath; + + return new FakeRootPathProvider(); + } + } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token.TestingDemo/packages.config b/src/Nancy.Demo.Authentication.Token.TestingDemo/packages.config new file mode 100644 index 0000000000..beaa94de20 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token.TestingDemo/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/AuthModule.cs b/src/Nancy.Demo.Authentication.Token/AuthModule.cs new file mode 100644 index 0000000000..31997d9f03 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/AuthModule.cs @@ -0,0 +1,45 @@ +namespace Nancy.Demo.Authentication.Token +{ + using Nancy.Authentication.Token; + using Security; + + public class AuthModule : NancyModule + { + public AuthModule(ITokenizer tokenizer) + : base("/auth") + { + Post["/"] = x => + { + var userName = (string)this.Request.Form.UserName; + var password = (string)this.Request.Form.Password; + + var userIdentity = UserDatabase.ValidateUser(userName, password); + + if (userIdentity == null) + { + return HttpStatusCode.Unauthorized; + } + + var token = tokenizer.Tokenize(userIdentity, Context); + + return new + { + Token = token, + }; + }; + + Get["/validation"] = _ => + { + this.RequiresAuthentication(); + return "Yay! You are authenticated!"; + }; + + Get["/admin"] = _ => + { + this.RequiresAuthentication(); + this.RequiresClaims(new[] { "admin" }); + return "Yay! You are authorized!"; + }; + } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/DemoUserIdentity.cs b/src/Nancy.Demo.Authentication.Token/DemoUserIdentity.cs new file mode 100644 index 0000000000..db5d95807d --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/DemoUserIdentity.cs @@ -0,0 +1,11 @@ +namespace Nancy.Demo.Authentication.Token +{ + using System.Collections.Generic; + using Security; + + public class DemoUserIdentity : IUserIdentity + { + public string UserName { get; set; } + public IEnumerable Claims { get; set; } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/Nancy.Demo.Authentication.Token.csproj b/src/Nancy.Demo.Authentication.Token/Nancy.Demo.Authentication.Token.csproj new file mode 100644 index 0000000000..4d37a03760 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/Nancy.Demo.Authentication.Token.csproj @@ -0,0 +1,101 @@ + + + + + Debug + AnyCPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01} + Exe + Properties + Nancy.Demo.Authentication.Token + Nancy.Demo.Authentication.Token + v4.0 + 512 + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + + + + + true + bin\MonoDebug\ + DEBUG;TRACE + full + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + bin\MonoRelease\ + TRACE + true + pdbonly + AnyCPU + prompt + MinimumRecommendedRules.ruleset + + + + + + + + + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + + + + {97fa024a-f6ed-4086-bcc1-1a51be63474c} + Nancy.Authentication.Token + + + {aa7f66eb-ec2c-47de-855f-30b3e6ef2134} + Nancy.Hosting.Self + + + {34576216-0dca-4b0f-a0dc-9075e75a676f} + Nancy + + + + + \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/Program.cs b/src/Nancy.Demo.Authentication.Token/Program.cs new file mode 100644 index 0000000000..79d83a9fdb --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/Program.cs @@ -0,0 +1,23 @@ +namespace Nancy.Demo.Authentication.Token +{ + using System; + using Hosting.Self; + + class Program + { + static void Main(string[] args) + { + var uri = + new Uri("http://localhost:3579"); + + using (var host = new NancyHost(uri)) + { + host.Start(); + + Console.WriteLine("Your application is running on " + uri); + Console.WriteLine("Press any [Enter] to close the host."); + Console.ReadLine(); + } + } + } +} diff --git a/src/Nancy.Demo.Authentication.Token/TokenAuthBootstrapper.cs b/src/Nancy.Demo.Authentication.Token/TokenAuthBootstrapper.cs new file mode 100644 index 0000000000..0e1aeb037f --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/TokenAuthBootstrapper.cs @@ -0,0 +1,27 @@ +namespace Nancy.Demo.Authentication.Token +{ + using System.Linq; + using Nancy; + using Nancy.Authentication.Token; + using Nancy.Bootstrapper; + using TinyIoc; + + public class TokenAuthBootstrapper : DefaultNancyBootstrapper + { + protected override void ConfigureApplicationContainer(TinyIoCContainer container) + { + // Example options for specifying additional values for token generation + + //container.Register(new Tokenizer(cfg => + // cfg.AdditionalItems( + // ctx => + // ctx.Request.Headers["X-Custom-Header"].FirstOrDefault(), + // ctx => ctx.Request.Query.extraValue))); + } + + protected override void RequestStartup(TinyIoCContainer container, IPipelines pipelines, NancyContext context) + { + TokenAuthentication.Enable(pipelines, new TokenAuthenticationConfiguration(container.Resolve())); + } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/UserDatabase.cs b/src/Nancy.Demo.Authentication.Token/UserDatabase.cs new file mode 100644 index 0000000000..f8a141ba39 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/UserDatabase.cs @@ -0,0 +1,34 @@ +namespace Nancy.Demo.Authentication.Token +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Security; + + public class UserDatabase + { + private static readonly List> Users = new List>(); + private static readonly Dictionary> Claims = new Dictionary>(); + + static UserDatabase() + { + Users.Add(new Tuple("demo", "demo")); + Claims.Add("demo", new List { "demo", "admin" }); + + Users.Add(new Tuple("nonadmin", "nonadmin")); + Claims.Add("nonadmin", new List { "demo", }); + } + + public static IUserIdentity ValidateUser(string userName, string password) + { + var user = Users.FirstOrDefault(x => x.Item1 == userName && x.Item2 == password); + if (user == null) + { + return null; + } + + var claims = Claims[user.Item1]; + return new DemoUserIdentity {UserName = user.Item1, Claims = claims}; + } + } +} \ No newline at end of file diff --git a/src/Nancy.Demo.Authentication.Token/app.config b/src/Nancy.Demo.Authentication.Token/app.config new file mode 100644 index 0000000000..cb2586beb1 --- /dev/null +++ b/src/Nancy.Demo.Authentication.Token/app.config @@ -0,0 +1,3 @@ + + + diff --git a/src/Nancy.sln b/src/Nancy.sln index f85df474e8..1b17cf7ac5 100644 --- a/src/Nancy.sln +++ b/src/Nancy.sln @@ -121,6 +121,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Owin.Tests", "Nancy.O EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Demo.ConstraintRouting", "Nancy.Demo.ConstraintRouting\Nancy.Demo.ConstraintRouting.csproj", "{972C2D45-49B6-4109-9A3A-0C8BC9225B95}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Authentication.Token.Tests", "Nancy.Authentication.Token.Tests\Nancy.Authentication.Token.Tests.csproj", "{3C131D45-AF1D-4659-8B26-A9F55EED0D20}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Authentication.Token", "Nancy.Authentication.Token\Nancy.Authentication.Token.csproj", "{97FA024A-F6ED-4086-BCC1-1A51BE63474C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Demo.Authentication.Token", "Nancy.Demo.Authentication.Token\Nancy.Demo.Authentication.Token.csproj", "{35460AA4-B94A-4B64-9418-7243EC3D2F01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nancy.Demo.Authentication.Token.TestingDemo", "Nancy.Demo.Authentication.Token.TestingDemo\Nancy.Demo.Authentication.Token.TestingDemo.csproj", "{9121DA01-7BFD-49F0-9937-0D3E7875ADB9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -801,6 +809,54 @@ Global {972C2D45-49B6-4109-9A3A-0C8BC9225B95}.Release|Any CPU.ActiveCfg = Release|Any CPU {972C2D45-49B6-4109-9A3A-0C8BC9225B95}.Release|Any CPU.Build.0 = Release|Any CPU {972C2D45-49B6-4109-9A3A-0C8BC9225B95}.Release|x86.ActiveCfg = Release|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoDebug|Any CPU.ActiveCfg = MonoDebug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoDebug|Any CPU.Build.0 = MonoDebug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoDebug|x86.ActiveCfg = Debug|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoRelease|Any CPU.ActiveCfg = MonoRelease|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoRelease|Any CPU.Build.0 = MonoRelease|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.MonoRelease|x86.ActiveCfg = Release|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Release|Any CPU.Build.0 = Release|Any CPU + {3C131D45-AF1D-4659-8B26-A9F55EED0D20}.Release|x86.ActiveCfg = Release|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Debug|x86.ActiveCfg = Debug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoDebug|Any CPU.ActiveCfg = MonoDebug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoDebug|Any CPU.Build.0 = MonoDebug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoDebug|x86.ActiveCfg = Debug|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoRelease|Any CPU.ActiveCfg = MonoRelease|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoRelease|Any CPU.Build.0 = MonoRelease|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.MonoRelease|x86.ActiveCfg = Release|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Release|Any CPU.Build.0 = Release|Any CPU + {97FA024A-F6ED-4086-BCC1-1A51BE63474C}.Release|x86.ActiveCfg = Release|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Debug|x86.ActiveCfg = Debug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoDebug|Any CPU.ActiveCfg = MonoDebug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoDebug|Any CPU.Build.0 = MonoDebug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoDebug|x86.ActiveCfg = Debug|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoRelease|Any CPU.ActiveCfg = MonoRelease|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoRelease|Any CPU.Build.0 = MonoRelease|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.MonoRelease|x86.ActiveCfg = Release|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Release|Any CPU.Build.0 = Release|Any CPU + {35460AA4-B94A-4B64-9418-7243EC3D2F01}.Release|x86.ActiveCfg = Release|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Debug|x86.ActiveCfg = Debug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoDebug|Any CPU.ActiveCfg = MonoDebug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoDebug|Any CPU.Build.0 = MonoDebug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoDebug|x86.ActiveCfg = Debug|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoRelease|Any CPU.ActiveCfg = MonoRelease|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoRelease|Any CPU.Build.0 = MonoRelease|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.MonoRelease|x86.ActiveCfg = Release|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Release|Any CPU.Build.0 = Release|Any CPU + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -825,6 +881,7 @@ Global {D6B2480C-2A52-4D02-9477-D387D7A486E5} = {E944109B-0B7A-4ADE-8602-004CEFA5897D} {D8EBA39B-63AE-4A66-BD0E-F7F95E572135} = {E944109B-0B7A-4ADE-8602-004CEFA5897D} {864AF449-0902-44FC-BEEE-06BA9A6F4A8F} = {E944109B-0B7A-4ADE-8602-004CEFA5897D} + {97FA024A-F6ED-4086-BCC1-1A51BE63474C} = {E944109B-0B7A-4ADE-8602-004CEFA5897D} {776D244D-BC4D-4BBB-A478-CAF2D04520E1} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} {3D44A461-3133-4B49-A74B-D25632A12FE5} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} {FE32460D-547C-4EB9-A768-541255CCAA83} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} @@ -843,6 +900,7 @@ Global {6EED2486-39BF-491D-B033-720DFCFD1211} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} {8EE6F976-F11F-44B3-B065-F5AAA598B30D} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} {34DDDA42-4041-4F92-87A6-F0E8CE12C7E3} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} + {3C131D45-AF1D-4659-8B26-A9F55EED0D20} = {A427F9F8-0A6F-4EEA-837F-FCDAB6E7D4B3} {5F2DD52D-471B-4946-8F7B-F050C88EFC52} = {4A24657F-9695-437B-9702-2541ED280628} {98940A30-1B48-4F71-A6BA-85F0AAF31A2F} = {4A24657F-9695-437B-9702-2541ED280628} {EF660223-4DFD-4E36-BF36-9DD6AFB3F837} = {4A24657F-9695-437B-9702-2541ED280628} @@ -862,6 +920,8 @@ Global {F56F3983-0C34-4AEC-B418-A8AFFA63F1C4} = {4A24657F-9695-437B-9702-2541ED280628} {B6929D0B-5104-4C7E-979D-F4914E813FA6} = {4A24657F-9695-437B-9702-2541ED280628} {972C2D45-49B6-4109-9A3A-0C8BC9225B95} = {4A24657F-9695-437B-9702-2541ED280628} + {35460AA4-B94A-4B64-9418-7243EC3D2F01} = {4A24657F-9695-437B-9702-2541ED280628} + {9121DA01-7BFD-49F0-9937-0D3E7875ADB9} = {4A24657F-9695-437B-9702-2541ED280628} EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution StartupItem = Nancy.Demo.Hosting.Aspnet\Nancy.Demo.Hosting.Aspnet.csproj diff --git a/src/packages/repositories.config b/src/packages/repositories.config index 33c2858849..a1591f85f0 100644 --- a/src/packages/repositories.config +++ b/src/packages/repositories.config @@ -2,7 +2,9 @@ + +