diff --git a/src/Owin.StatelessAuth.Tests/StatelessAuthTests.cs b/src/Owin.StatelessAuth.Tests/StatelessAuthTests.cs index 0b5da18..69bc171 100644 --- a/src/Owin.StatelessAuth.Tests/StatelessAuthTests.cs +++ b/src/Owin.StatelessAuth.Tests/StatelessAuthTests.cs @@ -1,11 +1,11 @@ namespace Owin.StatelessAuth.Tests { + using FakeItEasy; using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; - using FakeItEasy; using Xunit; using Xunit.Extensions; @@ -215,11 +215,10 @@ public void Should_Add_User_To_Owin_Environment() [Fact] public void Should_Override_User_In_Owin_Environment() { - //Given var fakeTokenValidator = A.Fake(); - var secureuser = new ClaimsPrincipal(); + var secureuser = new ClaimsPrincipal(); var claimsIdentity = new ClaimsIdentity("Token"); claimsIdentity.AddClaim(new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "DumbUser")); secureuser.AddIdentity(claimsIdentity); @@ -263,7 +262,7 @@ public void Should_Return_WWW_Authenticate_Header_In_Options() }; var owinhttps = GetStatelessAuth(GetNextFunc(), statelessAuthOptions: options); - + var environment = new Dictionary { { "owin.RequestHeaders", new Dictionary() }, @@ -279,6 +278,49 @@ public void Should_Return_WWW_Authenticate_Header_In_Options() Assert.Equal("Basic realm=\"WallyWorld\"", responseHeaders["WWW-Authenticate"].First()); } + [Fact] + public void Should_Execute_Next_If_Validated_Token_In_QueryString() + { + //Given + var owinhttps = GetStatelessAuth(GetNextFunc(), null, GetStatelessAuthOptionsQueryString()); + var environment = new Dictionary + { + { "owin.RequestHeaders", new Dictionary() }, + { "owin.RequestQueryString", "clientProtocol=1.5&Authorization=Token+eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJodHRwOi8vdHVlcmNvLmNvbSIsIkF1ZGllbmNlIjoiaHR0cDovL3RhbnRhbi5seSIsIkNsYWltcyI6W3siSXNzdWVyIjoiTE9DQUwgQVVUSE9SSVRZIiwiT3JpZ2luYWxJc3N1ZXIiOiJMT0NBTCBBVVRIT1JJVFkiLCJQcm9wZXJ0aWVzIjp7fSwiU3ViamVjdCI6bnVsbCwiVHlwZSI6Imh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL2VtYWlsYWRkcmVzcyIsIlZhbHVlIjoiaG9sYUBob2xhLmNvbSIsIlZhbHVlVHlwZSI6Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hI3N0cmluZyJ9LHsiSXNzdWVyIjoiTE9DQUwgQVVUSE9SSVRZIiwiT3JpZ2luYWxJc3N1ZXIiOiJMT0NBTCBBVVRIT1JJVFkiLCJQcm9wZXJ0aWVzIjp7fSwiU3ViamVjdCI6bnVsbCwiVHlwZSI6Imh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWVpZGVudGlmaWVyIiwiVmFsdWUiOiJob2xhIiwiVmFsdWVUeXBlIjoiaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEjc3RyaW5nIn0seyJJc3N1ZXIiOiJMT0NBTCBBVVRIT1JJVFkiLCJPcmlnaW5hbElzc3VlciI6IkxPQ0FMIEFVVEhPUklUWSIsIlByb3BlcnRpZXMiOnt9LCJTdWJqZWN0IjpudWxsLCJUeXBlIjoiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9wcmltYXJ5c2lkIiwiVmFsdWUiOiJVc2VyLzE4Njc5MDY2MjM3NzkiLCJWYWx1ZVR5cGUiOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSNzdHJpbmcifSx7Iklzc3VlciI6IkxPQ0FMIEFVVEhPUklUWSIsIk9yaWdpbmFsSXNzdWVyIjoiTE9DQUwgQVVUSE9SSVRZIiwiUHJvcGVydGllcyI6e30sIlN1YmplY3QiOm51bGwsIlR5cGUiOiJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiLCJWYWx1ZSI6IlBybyIsIlZhbHVlVHlwZSI6Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hI3N0cmluZyJ9XSwiRXhwaXJ5IjoiXC9EYXRlKDE0MzE0NjI4NzQ3MDcpXC8ifQ.eTTyY8fQKVgssmo42Gz82Ns742zD5uGo3PAjT_xEGMw&connectionData=%5B%7B%22name%22%3A%22superhub%22%7D%5D&_=1431419951254" }, + { "owin.RequestPath", "/" } + }; + + //When + var task = owinhttps.Invoke(environment); + + //Then + Assert.Equal(true, task.IsCompleted); + Assert.Equal(123, ((Task)task).Result); + } + + [Theory] + [InlineData("/api/user")] + [InlineData("/api/user/js/main.css")] + public void Should_Return_401_If_Request_Path_Doesnt_Meet_Ignore_List_And_Empty_Auth_Header_And_Not_QueryString(string requestpath) + { + //Given + var owinhttps = GetStatelessAuth(GetNextFunc(), statelessAuthOptions: new StatelessAuthOptions() { IgnorePaths = new List() { "/api/user/js/*.js", "/api/products" }, VerifyAuthenticationQueryString = true }); + var environment = new Dictionary + { + { "owin.RequestHeaders", new Dictionary() { { "Authorization", new[] { "" } } } }, //empty header so it falls through ignorelist check + { "owin.RequestQueryString", "" }, + { "owin.RequestPath", requestpath } + }; + + //When + var task = owinhttps.Invoke(environment); + + //Then + Assert.Equal(401, environment["owin.ResponseStatusCode"]); + Assert.Equal(true, task.IsCompleted); + Assert.Equal(0, ((Task)task).Result); + } + public Func, Task> GetNextFunc() { return objects => Task.FromResult(123); @@ -296,6 +338,11 @@ private StatelessAuthOptions GetStatelessAuthOptions() return new StatelessAuthOptions() { IgnorePaths = Enumerable.Empty(), WWWAuthenticateChallenge = "Digest" }; } + private StatelessAuthOptions GetStatelessAuthOptionsQueryString() + { + return new StatelessAuthOptions() { IgnorePaths = Enumerable.Empty(), WWWAuthenticateChallenge = "Digest", VerifyAuthenticationQueryString = true }; + } + private ITokenValidator GetFakeTokenValidator() { var fakeTokenValidator = A.Fake(); @@ -306,4 +353,4 @@ private ITokenValidator GetFakeTokenValidator() return fakeTokenValidator; } } -} +} \ No newline at end of file diff --git a/src/Owin.StatelessAuth/Owin.StatelessAuth.csproj b/src/Owin.StatelessAuth/Owin.StatelessAuth.csproj index 84dfe77..fcb5c65 100644 --- a/src/Owin.StatelessAuth/Owin.StatelessAuth.csproj +++ b/src/Owin.StatelessAuth/Owin.StatelessAuth.csproj @@ -30,8 +30,9 @@ 4 - + ..\packages\Minimatch.1.1.0.0\lib\portable-net40+sl50+win+wp80\Minimatch.dll + True ..\packages\Owin.1.0\lib\net40\Owin.dll @@ -48,6 +49,7 @@ + diff --git a/src/Owin.StatelessAuth/StatelessAuth.cs b/src/Owin.StatelessAuth/StatelessAuth.cs index e8363c4..af3cb15 100644 --- a/src/Owin.StatelessAuth/StatelessAuth.cs +++ b/src/Owin.StatelessAuth/StatelessAuth.cs @@ -1,12 +1,12 @@ namespace Owin.StatelessAuth { + using Minimatch; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; - using Minimatch; public class StatelessAuth { @@ -35,7 +35,7 @@ public Task Invoke(IDictionary environment) { foreach (var ignorePath in statelessAuthOptions.IgnorePaths) { - var mm = new Minimatcher(ignorePath, new Options(){IgnoreCase = true}); + var mm = new Minimatcher(ignorePath, new Options() { IgnoreCase = true }); if (mm.IsMatch(path)) { @@ -44,14 +44,14 @@ public Task Invoke(IDictionary environment) } } - var requestHeaders = (IDictionary)environment["owin.RequestHeaders"]; - if (!requestHeaders.ContainsKey("Authorization")) + string token = VerifyAuthorizationHeader(environment); + + if (token == null && statelessAuthOptions.VerifyAuthenticationQueryString) { - return AuthChallengeResponse(environment); + token = VerifyAuthorizationQueryString(environment); } - var token = requestHeaders["Authorization"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(token)) + if (token == null) { return AuthChallengeResponse(environment); } @@ -75,6 +75,27 @@ public Task Invoke(IDictionary environment) return nextFunc(environment); } + private string VerifyAuthorizationHeader(IDictionary environment) + { + string token = null; + var requestHeaders = (IDictionary)environment["owin.RequestHeaders"]; + if (requestHeaders.ContainsKey("Authorization")) + { + token = requestHeaders["Authorization"].FirstOrDefault(); + token = (string.IsNullOrEmpty(token) ? null : token); + } + + return token; + } + + private string VerifyAuthorizationQueryString(IDictionary environment) + { + var queryString = environment["owin.RequestQueryString"] as string; + var query = new UrlEncodingParser(queryString, statelessAuthOptions.DecodePlusSignsAsSpacesQueryString); + var token = query["Authorization"]; + return (string.IsNullOrEmpty(token) ? null : token); + } + private Task AuthChallengeResponse(IDictionary environment) { if (statelessAuthOptions != null && statelessAuthOptions.PassThroughUnauthorizedRequests) @@ -100,4 +121,4 @@ private Task AuthChallengeResponse(IDictionary environment) return Task.FromResult(0); } } -} +} \ No newline at end of file diff --git a/src/Owin.StatelessAuth/StatelessAuthOptions.cs b/src/Owin.StatelessAuth/StatelessAuthOptions.cs index 6df77d9..812ebb9 100644 --- a/src/Owin.StatelessAuth/StatelessAuthOptions.cs +++ b/src/Owin.StatelessAuth/StatelessAuthOptions.cs @@ -5,7 +5,13 @@ public class StatelessAuthOptions { public IEnumerable IgnorePaths { get; set; } + public string WWWAuthenticateChallenge { get; set; } + public bool PassThroughUnauthorizedRequests { get; set; } + + public bool VerifyAuthenticationQueryString { get; set; } + + public bool DecodePlusSignsAsSpacesQueryString { get; set; } } -} +} \ No newline at end of file diff --git a/src/Owin.StatelessAuth/UrlEncodingParser.cs b/src/Owin.StatelessAuth/UrlEncodingParser.cs new file mode 100644 index 0000000..47eb6cc --- /dev/null +++ b/src/Owin.StatelessAuth/UrlEncodingParser.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Owin.StatelessAuth +{ + /// + /// A query string or UrlEncoded form parser and editor + /// class that allows reading and writing of urlencoded + /// key value pairs used for query string and HTTP + /// form data. + /// + /// Useful for parsing and editing querystrings inside + /// of non-Web code that doesn't have easy access to + /// the HttpUtility class. + /// + /// + /// Supports multiple values per key + /// https://github.com/RickStrahl/WestwindToolkit/blob/master/Westwind.Utilities/SupportClasses/UrlEncodingParser.cs + /// + internal class UrlEncodingParser : NameValueCollection + { + /// + /// Holds the original Url that was assigned if any + /// Url must contain // to be considered a url + /// + private string Url { get; set; } + + /// + /// Determines whether plus signs in the UrlEncoded content + /// are treated as spaces. + /// + internal bool DecodePlusSignsAsSpaces { get; set; } + + /// + /// Always pass in a UrlEncoded data or a URL to parse from + /// unless you are creating a new one from scratch. + /// + /// + /// Pass a query string or raw Form data, or a full URL. + /// If a URL is parsed the part prior to the ? is stripped + /// but saved. Then when you write the original URL is + /// re-written with the new query string. + /// + internal UrlEncodingParser(string queryStringOrUrl = null, bool decodeSpacesAsPlusSigns = false) + { + Url = string.Empty; + DecodePlusSignsAsSpaces = decodeSpacesAsPlusSigns; + if (!string.IsNullOrEmpty(queryStringOrUrl)) + { + Parse(queryStringOrUrl); + } + } + + /// + /// Assigns multiple values to the same key + /// + /// + /// + internal void SetValues(string key, IEnumerable values) + { + foreach (var val in values) + Add(key, val); + } + + /// + /// Parses the query string into the internal dictionary + /// and optionally also returns this dictionary + /// + /// + /// Query string key value pairs or a full URL. If URL is + /// passed the URL is re-written in Write operation + /// + /// + internal NameValueCollection Parse(string query) + { + if (Uri.IsWellFormedUriString(query, UriKind.Absolute)) + Url = query; + + if (string.IsNullOrEmpty(query)) + Clear(); + else + { + int index = query.IndexOf('?'); + if (index > -1) + { + if (query.Length >= index + 1) + query = query.Substring(index + 1); + } + + var pairs = query.Split('&'); + foreach (var pair in pairs) + { + int index2 = pair.IndexOf('='); + if (index2 > 0) + { + var val = pair.Substring(index2 + 1); + if (!string.IsNullOrEmpty(val)) + { + if (DecodePlusSignsAsSpaces) + val = val.Replace("+", " "); + val = Uri.UnescapeDataString(val); + } + + Add(pair.Substring(0, index2), val); + } + } + } + + return this; + } + + /// + /// Writes out the urlencoded data/query string or full URL based + /// on the internally set values. + /// + /// urlencoded data or url + public override string ToString() + { + string query = string.Empty; + foreach (string key in Keys) + { + string[] values = GetValues(key); + foreach (var val in values) + { + query += key + "=" + Uri.EscapeUriString(val) + "&"; + } + } + query = query.Trim('&'); + + if (!string.IsNullOrEmpty(Url)) + { + if (Url.Contains("?")) + query = Url.Substring(0, Url.IndexOf('?') + 1) + query; + else + query = Url + "?" + query; + } + + return query; + } + } +} \ No newline at end of file