From 27ed70475d5f965cc396c557bdf9019763e59927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Bertrand?= Date: Thu, 2 Jan 2014 10:14:20 +0100 Subject: [PATCH] Adding the OpenID 2.0 and Steam authentications Adding a generic OpenID 2.0 authentication and a steam specific one. --- NuGet/Owin.Security.Providers.nuspec | 10 +- Owin.Security.Providers/OpenID/Constants.cs | 8 + .../OpenID/Infrastructure/Message.cs | 150 +++++ .../OpenID/Infrastructure/Property.cs | 11 + .../OpenID/OpenIDAuthenticationExtensions.cs | 50 ++ .../OpenID/OpenIDAuthenticationHandler.cs | 536 ++++++++++++++++++ .../OpenID/OpenIDAuthenticationMiddleware.cs | 116 ++++ .../OpenID/OpenIDAuthenticationOptions.cs | 92 +++ .../Provider/IOpenIDAuthenticationProvider.cs | 24 + .../Provider/OpenIDAuthenticatedContext.cs | 51 ++ .../Provider/OpenIDAuthenticationProvider.cs | 50 ++ .../Provider/OpenIDReturnEndpointContext.cs | 21 + .../Owin.Security.Providers.csproj | 16 + .../Steam/SteamAuthenticationExtensions.cs | 50 ++ .../Steam/SteamAuthenticationHandler.cs | 36 ++ .../Steam/SteamAuthenticationMiddleware.cs | 27 + .../Steam/SteamAuthenticationOptions.cs | 9 + ...OwinOAuthProvidersDemo-20131113093838.mdf} | Bin 3211264 -> 3211264 bytes ...OAuthProvidersDemo-20131113093838_log.ldf} | Bin 1048576 -> 1048576 bytes .../App_Start/Startup.Auth.cs | 12 + .../Account/_ExternalLoginsListPartial.cshtml | 14 +- OwinOAuthProvidersDemo/Web.config | 2 +- README.md | 3 + 23 files changed, 1277 insertions(+), 11 deletions(-) create mode 100644 Owin.Security.Providers/OpenID/Constants.cs create mode 100644 Owin.Security.Providers/OpenID/Infrastructure/Message.cs create mode 100644 Owin.Security.Providers/OpenID/Infrastructure/Property.cs create mode 100644 Owin.Security.Providers/OpenID/OpenIDAuthenticationExtensions.cs create mode 100644 Owin.Security.Providers/OpenID/OpenIDAuthenticationHandler.cs create mode 100644 Owin.Security.Providers/OpenID/OpenIDAuthenticationMiddleware.cs create mode 100644 Owin.Security.Providers/OpenID/OpenIDAuthenticationOptions.cs create mode 100644 Owin.Security.Providers/OpenID/Provider/IOpenIDAuthenticationProvider.cs create mode 100644 Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticatedContext.cs create mode 100644 Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticationProvider.cs create mode 100644 Owin.Security.Providers/OpenID/Provider/OpenIDReturnEndpointContext.cs create mode 100644 Owin.Security.Providers/Steam/SteamAuthenticationExtensions.cs create mode 100644 Owin.Security.Providers/Steam/SteamAuthenticationHandler.cs create mode 100644 Owin.Security.Providers/Steam/SteamAuthenticationMiddleware.cs create mode 100644 Owin.Security.Providers/Steam/SteamAuthenticationOptions.cs rename OwinOAuthProvidersDemo/App_Data/{aspnet-OwinOAuthProvidersDemo-20131113093833.mdf => aspnet-OwinOAuthProvidersDemo-20131113093838.mdf} (93%) rename OwinOAuthProvidersDemo/App_Data/{aspnet-OwinOAuthProvidersDemo-20131113093833_log.ldf => aspnet-OwinOAuthProvidersDemo-20131113093838_log.ldf} (50%) diff --git a/NuGet/Owin.Security.Providers.nuspec b/NuGet/Owin.Security.Providers.nuspec index 1bf302f..bf38cfd 100644 --- a/NuGet/Owin.Security.Providers.nuspec +++ b/NuGet/Owin.Security.Providers.nuspec @@ -2,8 +2,8 @@ Owin.Security.Providers - 1.1.0 - Jerrie Pelser + 1.2.0 + Jerrie Pelser, Jérémie Bertrand Jerrie Pelser http://opensource.org/licenses/MIT https://github.com/owin-middleware/OwinOAuthProviders @@ -13,12 +13,14 @@ - LinkedIn - Yahoo - GitHub + - OpenID 2.0 providers + - Steam - Adds support for authenticating against GitHub + Adds support for authenticating against OpenID 2.0 providers and Steam Copyright 2013 - owin katana oauth linkedin yahoo github + owin katana oauth linkedin yahoo github openid steam diff --git a/Owin.Security.Providers/OpenID/Constants.cs b/Owin.Security.Providers/OpenID/Constants.cs new file mode 100644 index 0000000..3486f23 --- /dev/null +++ b/Owin.Security.Providers/OpenID/Constants.cs @@ -0,0 +1,8 @@ + +namespace Owin.Security.Providers.OpenID +{ + internal static class Constants + { + internal const string DefaultAuthenticationType = "OpenID"; + } +} diff --git a/Owin.Security.Providers/OpenID/Infrastructure/Message.cs b/Owin.Security.Providers/OpenID/Infrastructure/Message.cs new file mode 100644 index 0000000..dba4982 --- /dev/null +++ b/Owin.Security.Providers/OpenID/Infrastructure/Message.cs @@ -0,0 +1,150 @@ +using Microsoft.Owin; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Owin.Security.Providers.OpenID.Infrastructure +{ + internal class Message + { + public Message(IReadableStringCollection parameters, bool strict) + { + Namespaces = new Dictionary(StringComparer.Ordinal); + Properties = new Dictionary(parameters.Count(), StringComparer.Ordinal); + Add(parameters, strict); + } + + public Dictionary Namespaces { get; private set; } + public Dictionary Properties { get; private set; } + + /// + /// Adds the openid parameters from querystring or form body into Namespaces and Properties collections. + /// This normalizes the parameter name, by replacing the variable namespace alias with the + /// actual namespace in the collection's key, and will optionally skip any parameters that are + /// not signed if the strict argument is true. + /// + /// The keys and values of the incoming querystring or form body + /// True if keys that are not signed should be ignored + private void Add(IReadableStringCollection parameters, bool strict) + { + IEnumerable> addingParameters; + + // strict is true if keys that are not signed should be strict + if (strict) + { + IList signed = parameters.GetValues("openid.signed"); + if (signed == null || + signed.Count != 1) + { + // nothing is added if the signed parameter is not present + return; + } + + // determine the set of keys that are signed, or which may be used without + // signing. ns, mode, signed, and sig each may be used without signing. + var strictKeys = new HashSet(signed[0] + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(value => "openid." + value) + .Concat(new[] { "openid.ns", "openid.mode", "openid.signed", "openid.sig" })); + + // the parameters to add are only the parameters what are in this set + addingParameters = parameters.Where(kv => strictKeys.Contains(kv.Key)); + } + else + { + // when strict is false all of the incoming parameters are to be added + addingParameters = parameters; + } + + // convert the incoming parameter strings into Property objects. the + // Key is the raw key name. The Name starts of being equal to Key with a + // trailing dot appended. The Value is the query or form value, with a comma delimiter + // inserted between multiply occuring values. + Property[] addingProperties = addingParameters.Select(kv => new Property + { + Key = kv.Key, + Name = kv.Key + ".", + Value = string.Join(",", kv.Value) + }).ToArray(); + + // first, recognize which parameters are namespace declarations + + var namespacePrefixes = new Dictionary(StringComparer.Ordinal); + foreach (var item in addingProperties) + { + // namespaces appear as with "openid.ns" or "openid.ns.alias" + if (item.Name.StartsWith("openid.ns.", StringComparison.Ordinal)) + { + // the value of the parameter is the uri of the namespace + item.Namespace = item.Value; + item.Name = "openid." + item.Name.Substring("openid.ns.".Length); + + // the namespaces collection is keyed by the ns uri + Namespaces.Add(item.Namespace, item); + + // and the prefixes collection is keyed by "openid.alias." + namespacePrefixes.Add(item.Name, item); + } + } + + // second, recognize which parameters are property values + + foreach (var item in addingProperties) + { + // anything with a namespace was already added to Namespaces + if (item.Namespace == null) + { + // look for the namespace match for this property. + Property match = null; + + // try finding where openid.alias.arg2 matches openid.ns.alies namespace + if (item.Name.StartsWith("openid.", StringComparison.Ordinal)) + { + int dotIndex = item.Name.IndexOf('.', "openid.".Length); + if (dotIndex != -1) + { + string namespacePrefix = item.Name.Substring(0, dotIndex + 1); + namespacePrefixes.TryGetValue(namespacePrefix, out match); + } + } + + // then try finding where openid.arg1 should match openid.ns namespace + if (match == null) + { + namespacePrefixes.TryGetValue("openid.", out match); + } + + // when a namespace is found + if (match != null) + { + // the property's namespace is defined, and the namespace's prefix is removed + item.Namespace = match.Namespace; + item.Name = item.Name.Substring(match.Name.Length); + } + + // the resulting property key is keyed by the local name and namespace + // so "openid.arg1" becomes "arg1.namespace-uri-of-openid" + // and "openid.alias.arg2" becomes "arg2.namespace-uri-of-alias" + Properties.Add(item.Name + item.Namespace, item); + } + } + } + + public bool TryGetValue(string key, out string value) + { + Property property; + if (Properties.TryGetValue(key, out property)) + { + value = property.Value; + return true; + } + value = null; + return false; + } + + public IEnumerable> ToFormValues() + { + return Namespaces.Concat(Properties).Select(pair => new KeyValuePair(pair.Value.Key, pair.Value.Value)); + } + } +} diff --git a/Owin.Security.Providers/OpenID/Infrastructure/Property.cs b/Owin.Security.Providers/OpenID/Infrastructure/Property.cs new file mode 100644 index 0000000..16e402c --- /dev/null +++ b/Owin.Security.Providers/OpenID/Infrastructure/Property.cs @@ -0,0 +1,11 @@ + +namespace Owin.Security.Providers.OpenID.Infrastructure +{ + internal class Property + { + public string Key { get; set; } + public string Namespace { get; set; } + public string Name { get; set; } + public string Value { get; set; } + } +} diff --git a/Owin.Security.Providers/OpenID/OpenIDAuthenticationExtensions.cs b/Owin.Security.Providers/OpenID/OpenIDAuthenticationExtensions.cs new file mode 100644 index 0000000..8e43c1a --- /dev/null +++ b/Owin.Security.Providers/OpenID/OpenIDAuthenticationExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Owin; +using System; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Extension methods for using + /// + public static class OpenIDAuthenticationExtensions + { + /// + /// Authenticate users using an OpenID provider + /// + /// The passed to the configuration method + /// Middleware configuration options + /// The updated + public static IAppBuilder UseOpenIDAuthentication(this IAppBuilder app, OpenIDAuthenticationOptions options) + { + if (app == null) + { + throw new ArgumentNullException("app"); + } + if (options == null) + { + throw new ArgumentNullException("options"); + } + + app.Use(typeof(OpenIDAuthenticationMiddleware), app, options); + return app; + } + + /// + /// Authenticate users using an OpenID provider + /// + /// The passed to the configuration method + /// The uri of the OpenID provider + /// Name of the OpenID provider + /// The updated + public static IAppBuilder UseOpenIDAuthentication(this IAppBuilder app, string providerUri, string providerName) + { + return UseOpenIDAuthentication(app, new OpenIDAuthenticationOptions + { + ProviderDiscoveryUri = providerUri, + Caption = providerName, + AuthenticationType = providerName, + CallbackPath = new PathString("/signin-openid" + providerName.ToLowerInvariant()) + }); + } + } +} diff --git a/Owin.Security.Providers/OpenID/OpenIDAuthenticationHandler.cs b/Owin.Security.Providers/OpenID/OpenIDAuthenticationHandler.cs new file mode 100644 index 0000000..943c769 --- /dev/null +++ b/Owin.Security.Providers/OpenID/OpenIDAuthenticationHandler.cs @@ -0,0 +1,536 @@ +using Microsoft.Owin; +using Microsoft.Owin.Infrastructure; +using Microsoft.Owin.Logging; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Infrastructure; +using Owin.Security.Providers.OpenID.Infrastructure; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; + +namespace Owin.Security.Providers.OpenID +{ + internal class OpenIDAuthenticationHandler : OpenIDAuthenticationHandlerBase + { + public OpenIDAuthenticationHandler(HttpClient httpClient, ILogger logger) + : base(httpClient, logger) + { } + } + + internal abstract class OpenIDAuthenticationHandlerBase : AuthenticationHandler where T : OpenIDAuthenticationOptions + { + private const string CONTENTTYPE_XRDS = "application/xrds+xml"; + private const string CONTENTTYPE_HTML = "text/html"; + private const string CONTENTTYPE_XHTML = "application/xhtml+xml"; + private const string CONTENTTYPE_XML = "text/xml"; + private const string XRDS_LOCATIONHEADER = "X-XRDS-Location"; + private const string XRD_NAMESPACE = "xri://$xrd*($v*2.0)"; + + protected readonly ILogger _logger; + protected readonly HttpClient _httpClient; + + public OpenIDAuthenticationHandlerBase(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public override async Task InvokeAsync() + { + if (Options.CallbackPath.HasValue && Options.CallbackPath == Request.Path) + { + return await InvokeReturnPathAsync(); + } + return false; + } + + protected override async Task AuthenticateCoreAsync() + { + AuthenticationProperties properties = null; + + try + { + IReadableStringCollection query = Request.Query; + + properties = UnpackStateParameter(query); + if (properties == null) + { + _logger.WriteWarning("Invalid return state"); + return null; + } + + // Anti-CSRF + if (!ValidateCorrelationId(properties, _logger)) + { + return new AuthenticationTicket(null, properties); + } + + Message message = await ParseRequestMessageAsync(query); + + bool messageValidated = false; + + Property mode; + if (!message.Properties.TryGetValue("mode.http://specs.openid.net/auth/2.0", out mode)) + { + _logger.WriteWarning("Missing mode parameter"); + return new AuthenticationTicket(null, properties); + } + + if (string.Equals("cancel", mode.Value, StringComparison.Ordinal)) + { + _logger.WriteWarning("User cancelled signin request"); + return new AuthenticationTicket(null, properties); + } + + if (string.Equals("id_res", mode.Value, StringComparison.Ordinal)) + { + mode.Value = "check_authentication"; + + var requestBody = new FormUrlEncodedContent(message.ToFormValues()); + + HttpResponseMessage response = await _httpClient.PostAsync(Options.ProviderLoginUri, requestBody, Request.CallCancelled); + response.EnsureSuccessStatusCode(); + string responseBody = await response.Content.ReadAsStringAsync(); + + var verifyBody = new Dictionary(); + foreach (var line in responseBody.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + int delimiter = line.IndexOf(':'); + if (delimiter != -1) + { + verifyBody.Add("openid." + line.Substring(0, delimiter), new[] { line.Substring(delimiter + 1) }); + } + } + var verifyMessage = new Message(new ReadableStringCollection(verifyBody), strict: false); + Property isValid; + if (verifyMessage.Properties.TryGetValue("is_valid.http://specs.openid.net/auth/2.0", out isValid)) + { + if (string.Equals("true", isValid.Value, StringComparison.Ordinal)) + { + messageValidated = true; + } + else + { + messageValidated = false; + } + } + } + + // http://openid.net/specs/openid-authentication-2_0.html#verify_return_to + // To verify that the "openid.return_to" URL matches the URL that is processing this assertion: + // * The URL scheme, authority, and path MUST be the same between the two URLs. + // * Any query parameters that are present in the "openid.return_to" URL MUST also + // be present with the same values in the URL of the HTTP request the RP received. + if (messageValidated) + { + // locate the required return_to parameter + string actualReturnTo; + if (!message.TryGetValue("return_to.http://specs.openid.net/auth/2.0", out actualReturnTo)) + { + _logger.WriteWarning("openid.return_to parameter missing at return address"); + messageValidated = false; + } + else + { + // create the expected return_to parameter based on the URL that is processing + // the assertion, plus exactly and only the the query string parameter (state) + // that this RP must have received + string expectedReturnTo = BuildReturnTo(GetStateParameter(query)); + + if (!string.Equals(actualReturnTo, expectedReturnTo, StringComparison.Ordinal)) + { + _logger.WriteWarning("openid.return_to parameter not equal to expected value based on return address"); + messageValidated = false; + } + } + } + + if (messageValidated) + { + IDictionary attributeExchangeProperties = new Dictionary(); + foreach (var typeProperty in message.Properties.Values) + { + if (typeProperty.Namespace == "http://openid.net/srv/ax/1.0" && + typeProperty.Name.StartsWith("type.")) + { + string qname = "value." + typeProperty.Name.Substring("type.".Length) + "http://openid.net/srv/ax/1.0"; + Property valueProperty; + if (message.Properties.TryGetValue(qname, out valueProperty)) + { + attributeExchangeProperties.Add(typeProperty.Value, valueProperty.Value); + } + } + } + + var responseNamespaces = new object[] + { + new XAttribute(XNamespace.Xmlns + "openid", "http://specs.openid.net/auth/2.0"), + new XAttribute(XNamespace.Xmlns + "openid.ax", "http://openid.net/srv/ax/1.0") + }; + + IEnumerable responseProperties = message.Properties + .Where(p => p.Value.Namespace != null) + .Select(p => (object)new XElement(XName.Get(p.Value.Name.Substring(0, p.Value.Name.Length - 1), p.Value.Namespace), p.Value.Value)); + + var responseMessage = new XElement("response", responseNamespaces.Concat(responseProperties).ToArray()); + + var identity = new ClaimsIdentity(Options.AuthenticationType); + XElement claimedId = responseMessage.Element(XName.Get("claimed_id", "http://specs.openid.net/auth/2.0")); + if (claimedId != null) + { + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, claimedId.Value, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); + } + + SetIdentityInformations(identity, claimedId.Value, attributeExchangeProperties); + + var context = new OpenIDAuthenticatedContext( + Context, + identity, + properties, + responseMessage, + attributeExchangeProperties); + + await Options.Provider.Authenticated(context); + + return new AuthenticationTicket(context.Identity, context.Properties); + } + + return new AuthenticationTicket(null, properties); + } + catch (Exception ex) + { + _logger.WriteError("Authentication failed", ex); + return new AuthenticationTicket(null, properties); + } + } + + protected virtual void SetIdentityInformations(ClaimsIdentity identity, string claimedID, IDictionary attributeExchangeProperties) + { + string emailValue; + if (attributeExchangeProperties.TryGetValue("http://axschema.org/contact/email", out emailValue)) + { + identity.AddClaim(new Claim(ClaimTypes.Email, emailValue, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); + } + + string firstValue; + if (attributeExchangeProperties.TryGetValue("http://axschema.org/namePerson/first", out firstValue)) + { + identity.AddClaim(new Claim(ClaimTypes.GivenName, firstValue, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); + } + + string lastValue; + if (attributeExchangeProperties.TryGetValue("http://axschema.org/namePerson/last", out lastValue)) + { + identity.AddClaim(new Claim(ClaimTypes.Surname, lastValue, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); + } + + string nameValue; + if (!attributeExchangeProperties.TryGetValue("http://axschema.org/namePerson", out nameValue)) + { + if (!string.IsNullOrEmpty(firstValue) && !string.IsNullOrEmpty(lastValue)) + { + nameValue = firstValue + " " + lastValue; + } + else if (!string.IsNullOrEmpty(firstValue)) + { + nameValue = firstValue; + } + else if (!string.IsNullOrEmpty(lastValue)) + { + nameValue = lastValue; + } + else + { + nameValue = emailValue.Substring(0, emailValue.IndexOf('@')); + } + } + + identity.AddClaim(new Claim(ClaimTypes.Name, nameValue, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); + + } + + private static string GetStateParameter(IReadableStringCollection query) + { + IList values = query.GetValues("state"); + if (values != null && values.Count == 1) + { + return values[0]; + } + return null; + } + + private AuthenticationProperties UnpackStateParameter(IReadableStringCollection query) + { + string state = GetStateParameter(query); + if (state != null) + { + return Options.StateDataFormat.Unprotect(state); + } + return null; + } + + private string BuildReturnTo(string state) + { + return Request.Scheme + "://" + Request.Host + + RequestPathBase + Options.CallbackPath + + "?state=" + Uri.EscapeDataString(state); + } + + private async Task ParseRequestMessageAsync(IReadableStringCollection query) + { + if (Request.Method == "POST") + { + IFormCollection form = await Request.ReadFormAsync(); + return new Message(form, strict: true); + } + return new Message(query, strict: true); + } + + protected override Task ApplyResponseChallengeAsync() + { + if (Response.StatusCode != 401) + { + return Task.FromResult(null); + } + + AuthenticationResponseChallenge challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode); + + if (challenge != null) + { + DoYadisDiscovery(); + + if (!string.IsNullOrEmpty(Options.ProviderLoginUri)) + { + string requestPrefix = Request.Scheme + Uri.SchemeDelimiter + Request.Host; + + var state = challenge.Properties; + if (String.IsNullOrEmpty(state.RedirectUri)) + { + state.RedirectUri = requestPrefix + Request.PathBase + Request.Path + Request.QueryString; + } + + // Anti-CSRF + GenerateCorrelationId(state); + + string returnTo = BuildReturnTo(Options.StateDataFormat.Protect(state)); + + string authorizationEndpoint = + Options.ProviderLoginUri + + "?openid.ns=" + Uri.EscapeDataString("http://specs.openid.net/auth/2.0") + + "&openid.mode=" + Uri.EscapeDataString("checkid_setup") + + "&openid.claimed_id=" + Uri.EscapeDataString("http://specs.openid.net/auth/2.0/identifier_select") + + "&openid.identity=" + Uri.EscapeDataString("http://specs.openid.net/auth/2.0/identifier_select") + + "&openid.return_to=" + Uri.EscapeDataString(returnTo) + + "&openid.realm=" + Uri.EscapeDataString(requestPrefix) + + + "&openid.ns.ax=" + Uri.EscapeDataString("http://openid.net/srv/ax/1.0") + + "&openid.ax.mode=" + Uri.EscapeDataString("fetch_request") + + + "&openid.ax.type.email=" + Uri.EscapeDataString("http://axschema.org/contact/email") + + "&openid.ax.type.name=" + Uri.EscapeDataString("http://axschema.org/namePerson") + + "&openid.ax.type.first=" + Uri.EscapeDataString("http://axschema.org/namePerson/first") + + "&openid.ax.type.last=" + Uri.EscapeDataString("http://axschema.org/namePerson/last") + + + "&openid.ax.type.email2=" + Uri.EscapeDataString("http://schema.openid.net/contact/email") + + "&openid.ax.type.name2=" + Uri.EscapeDataString("http://schema.openid.net/namePerson") + + "&openid.ax.type.first2=" + Uri.EscapeDataString("http://schema.openid.net/namePerson/first") + + "&openid.ax.type.last2=" + Uri.EscapeDataString("http://schema.openid.net/namePerson/last") + + + "&openid.ax.required=" + Uri.EscapeDataString("email,name,first,last,email2,name2,first2,last2"); + + Response.StatusCode = 302; + Response.Headers.Set("Location", authorizationEndpoint); + } + } + + return Task.FromResult(null); + } + + private void DoYadisDiscovery() + { + // 1° request + HttpResponseMessage httpResponse = SendRequest(Options.ProviderDiscoveryUri, CONTENTTYPE_XRDS, CONTENTTYPE_HTML, CONTENTTYPE_XHTML); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + _logger.WriteError(string.Format("HTTP error {0} ({1}) while performing discovery on {2}.", (int)httpResponse.StatusCode, httpResponse.StatusCode, Options.ProviderDiscoveryUri)); + return; + } + + httpResponse.Content.LoadIntoBufferAsync().Wait(); + + // 2° request (if necessary) + if (!IsXrdsDocument(httpResponse)) + { + IEnumerable uriStrings; + string uriString = null; + if (httpResponse.Headers.TryGetValues(XRDS_LOCATIONHEADER, out uriStrings)) + { + uriString = uriStrings.FirstOrDefault(); + } + + Uri url = null; + if (uriString != null) + { + Uri.TryCreate(uriString, UriKind.Absolute, out url); + } + + var contentType = httpResponse.Content.Headers.ContentType; + if (url == null && contentType != null && (contentType.MediaType == CONTENTTYPE_HTML || contentType.MediaType == CONTENTTYPE_XHTML)) + { + var readAsString = httpResponse.Content.ReadAsStringAsync(); + readAsString.Wait(); + url = FindYadisDocumentLocationInHtmlMetaTags(readAsString.Result); + } + if (url == null) + { + _logger.WriteError(string.Format("The uri {0} doesn't return an XRDS document.", Options.ProviderDiscoveryUri)); + return; + } + else + { + if (string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + httpResponse = SendRequest(url.AbsoluteUri, CONTENTTYPE_XRDS); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + _logger.WriteError(string.Format("HTTP error {0} {1} while performing discovery on {2}.", (int)httpResponse.StatusCode, httpResponse.StatusCode, url.AbsoluteUri)); + return; + } + if (!IsXrdsDocument(httpResponse)) + { + _logger.WriteError(string.Format("The uri {0} doesn't return an XRDS document.", url.AbsoluteUri)); + return; + } + } + } + } + + // Get the XRDS document + var readAsStringXrdsDoc = httpResponse.Content.ReadAsStringAsync(); + readAsStringXrdsDoc.Wait(); + + // Get provider url from XRDS document + XDocument xrdsDoc = XDocument.Parse(readAsStringXrdsDoc.Result); + Options.ProviderLoginUri = xrdsDoc.Root.Element(XName.Get("XRD", "xri://$xrd*($v*2.0)")) + .Descendants(XName.Get("Service", "xri://$xrd*($v*2.0)")) + .Where(service => service.Descendants(XName.Get("Type", "xri://$xrd*($v*2.0)")).Any(type => type.Value == "http://specs.openid.net/auth/2.0/server")) + .OrderBy(service => service.Attribute("priority").Value) + .Select(service => service.Element(XName.Get("URI", "xri://$xrd*($v*2.0)")).Value) + .FirstOrDefault(); + } + + // FIXME use an HTTP parser + private static readonly Regex MetaTagXRDSLocationRegex = new Regex(@"", RegexOptions.Compiled); + + private static Uri FindYadisDocumentLocationInHtmlMetaTags(string html) + { + var match = MetaTagXRDSLocationRegex.Match(html); + if (match.Success) + { + Uri uri; + if (Uri.TryCreate(match.Groups[1].Value, UriKind.Absolute, out uri)) + { + return uri; + } + } + return null; + } + + private HttpResponseMessage SendRequest(string uri, params string[] acceptTypes) + { + HttpRequestMessage httprequest = new HttpRequestMessage(HttpMethod.Get, uri); + if (acceptTypes != null) + { + foreach (string acceptType in acceptTypes) + { + httprequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptType)); + } + } + var sendRequest = _httpClient.SendAsync(httprequest); + sendRequest.Wait(); + return sendRequest.Result; + } + + private static bool IsXrdsDocument(HttpResponseMessage response) + { + if (response.Content.Headers.ContentType == null) + { + return false; + } + + if (response.Content.Headers.ContentType.MediaType == CONTENTTYPE_XRDS) + { + return true; + } + + if (response.Content.Headers.ContentType.MediaType == CONTENTTYPE_XML) + { + var readAsStream = response.Content.ReadAsStreamAsync(); + readAsStream.Wait(); + using (var responseStream = readAsStream.Result) + { + XmlReader reader = XmlReader.Create(responseStream, new XmlReaderSettings { MaxCharactersFromEntities = 1024, XmlResolver = null, DtdProcessing = DtdProcessing.Prohibit }); + var read = reader.ReadAsync(); + read.Wait(); + while (read.Result && reader.NodeType != XmlNodeType.Element) + { } + if (reader.NamespaceURI == XRD_NAMESPACE && reader.Name == "XRDS") + { + return true; + } + } + } + + return false; + } + + public async Task InvokeReturnPathAsync() + { + AuthenticationTicket model = await AuthenticateAsync(); + if (model == null) + { + _logger.WriteWarning("Invalid return state, unable to redirect."); + Response.StatusCode = 500; + return true; + } + + var context = new OpenIDReturnEndpointContext(Context, model); + context.SignInAsAuthenticationType = Options.SignInAsAuthenticationType; + context.RedirectUri = model.Properties.RedirectUri; + model.Properties.RedirectUri = null; + + await Options.Provider.ReturnEndpoint(context); + + if (context.SignInAsAuthenticationType != null && context.Identity != null) + { + ClaimsIdentity signInIdentity = context.Identity; + if (!string.Equals(signInIdentity.AuthenticationType, context.SignInAsAuthenticationType, StringComparison.Ordinal)) + { + signInIdentity = new ClaimsIdentity(signInIdentity.Claims, context.SignInAsAuthenticationType, signInIdentity.NameClaimType, signInIdentity.RoleClaimType); + } + Context.Authentication.SignIn(context.Properties, signInIdentity); + } + + if (!context.IsRequestCompleted && context.RedirectUri != null) + { + if (context.Identity == null) + { + // add a redirect hint that sign-in failed in some way + context.RedirectUri = WebUtilities.AddQueryString(context.RedirectUri, "error", "access_denied"); + } + Response.Redirect(context.RedirectUri); + context.RequestCompleted(); + } + + return context.IsRequestCompleted; + } + } +} diff --git a/Owin.Security.Providers/OpenID/OpenIDAuthenticationMiddleware.cs b/Owin.Security.Providers/OpenID/OpenIDAuthenticationMiddleware.cs new file mode 100644 index 0000000..f028a66 --- /dev/null +++ b/Owin.Security.Providers/OpenID/OpenIDAuthenticationMiddleware.cs @@ -0,0 +1,116 @@ +using Microsoft.Owin; +using Microsoft.Owin.Logging; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.DataHandler; +using Microsoft.Owin.Security.DataProtection; +using Microsoft.Owin.Security.Infrastructure; +using Owin.Security.Providers.Properties; +using System; +using System.Globalization; +using System.Net.Http; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// OWIN middleware for authenticating users using an OpenID provider + /// + public class OpenIDAuthenticationMiddleware : OpenIDAuthenticationMiddlewareBase + { + /// + /// Initializes a + /// + /// The next middleware in the OWIN pipeline to invoke + /// The OWIN application + /// Configuration options for the middleware + public OpenIDAuthenticationMiddleware(OwinMiddleware next, IAppBuilder app, OpenIDAuthenticationOptions options) + : base(next, app, options) + { } + + protected override AuthenticationHandler CreateSpecificHandler() + { + return new OpenIDAuthenticationHandler(_httpClient, _logger); + } + } + + /// + /// OWIN middleware for authenticating users using an OpenID provider + /// + public abstract class OpenIDAuthenticationMiddlewareBase : AuthenticationMiddleware where T : OpenIDAuthenticationOptions + { + protected readonly ILogger _logger; + protected readonly HttpClient _httpClient; + + /// + /// Initializes a + /// + /// The next middleware in the OWIN pipeline to invoke + /// The OWIN application + /// Configuration options for the middleware + public OpenIDAuthenticationMiddlewareBase(OwinMiddleware next, IAppBuilder app, T options) + : base(next, options) + { + if (String.IsNullOrWhiteSpace(Options.ProviderDiscoveryUri) && Options.AuthenticationType != Constants.DefaultAuthenticationType) + { + throw new ArgumentException(String.Format(CultureInfo.CurrentCulture, Resources.Exception_OptionMustBeProvided, "ProviderDiscoveryUri")); + } + + _logger = app.CreateLogger(); + + if (Options.Provider == null) + { + Options.Provider = new OpenIDAuthenticationProvider(); + } + + if (Options.StateDataFormat == null) + { + IDataProtector dataProtecter = app.CreateDataProtector( + typeof(OpenIDAuthenticationMiddleware).FullName, + Options.AuthenticationType, "v1"); + Options.StateDataFormat = new PropertiesDataFormat(dataProtecter); + } + + if (String.IsNullOrEmpty(Options.SignInAsAuthenticationType)) + { + Options.SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(); + } + + _httpClient = new HttpClient(ResolveHttpMessageHandler(Options)); + _httpClient.Timeout = Options.BackchannelTimeout; + _httpClient.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB + } + + /// + /// Provides the object for processing authentication-related requests. + /// + /// An configured with the supplied to the constructor. + protected override AuthenticationHandler CreateHandler() + { + return CreateSpecificHandler(); + } + + /// + /// Provides the object for processing authentication-related requests. + /// + /// An configured with the supplied to the constructor. + protected abstract AuthenticationHandler CreateSpecificHandler(); + + private static HttpMessageHandler ResolveHttpMessageHandler(OpenIDAuthenticationOptions options) + { + HttpMessageHandler handler = options.BackchannelHttpHandler ?? new WebRequestHandler(); + + // If they provided a validator, apply it or fail. + if (options.BackchannelCertificateValidator != null) + { + // Set the cert validate callback + var webRequestHandler = handler as WebRequestHandler; + if (webRequestHandler == null) + { + throw new InvalidOperationException(Resources.Exception_ValidatorHandlerMismatch); + } + webRequestHandler.ServerCertificateValidationCallback = options.BackchannelCertificateValidator.Validate; + } + + return handler; + } + } +} diff --git a/Owin.Security.Providers/OpenID/OpenIDAuthenticationOptions.cs b/Owin.Security.Providers/OpenID/OpenIDAuthenticationOptions.cs new file mode 100644 index 0000000..2856b26 --- /dev/null +++ b/Owin.Security.Providers/OpenID/OpenIDAuthenticationOptions.cs @@ -0,0 +1,92 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using System; +using System.Net.Http; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Configuration options for + /// + public class OpenIDAuthenticationOptions : AuthenticationOptions + { + /// + /// Gets or sets the a pinned certificate validator to use to validate the endpoints used + /// in back channel communications belong to the OpenID provider. + /// + /// + /// The pinned certificate validator. + /// + /// If this property is null then the default certificate checks are performed, + /// validating the subject name and if the signing chain is a trusted party. + public ICertificateValidator BackchannelCertificateValidator { get; set; } + + /// + /// Gets or sets timeout value in milliseconds for back channel communications with the OpenID provider. + /// + /// + /// The back channel timeout. + /// + public TimeSpan BackchannelTimeout { get; set; } + + /// + /// The HttpMessageHandler used to communicate with the OpenID provider. + /// This cannot be set at the same time as BackchannelCertificateValidator unless the value + /// can be downcast to a WebRequestHandler. + /// + public HttpMessageHandler BackchannelHttpHandler { get; set; } + + /// + /// Get or sets the text that the user can display on a sign in user interface. + /// + public string Caption + { + get { return Description.Caption; } + set { Description.Caption = value; } + } + + /// + /// The request path within the application's base path where the user-agent will be returned. + /// The middleware will process this request when it arrives. + /// Default value is "/signin-openid". + /// + public PathString CallbackPath { get; set; } + + /// + /// Gets or sets the name of another authentication middleware which will be responsible for actually issuing a user . + /// + public string SignInAsAuthenticationType { get; set; } + + /// + /// Gets or sets the used to handle authentication events. + /// + public IOpenIDAuthenticationProvider Provider { get; set; } + + /// + /// Gets or sets the type used to secure data handled by the middleware. + /// + public ISecureDataFormat StateDataFormat { get; set; } + + /// + /// The OpenID provider discovery uri + /// + public string ProviderDiscoveryUri { get; set; } + + /// + /// The OpenID provider login uri + /// + internal string ProviderLoginUri { get; set; } + + /// + /// Initializes a new + /// + public OpenIDAuthenticationOptions() + : base(Constants.DefaultAuthenticationType) + { + Caption = Constants.DefaultAuthenticationType; + CallbackPath = new PathString("/signin-openid"); + AuthenticationMode = AuthenticationMode.Passive; + BackchannelTimeout = TimeSpan.FromSeconds(60); + } + } +} diff --git a/Owin.Security.Providers/OpenID/Provider/IOpenIDAuthenticationProvider.cs b/Owin.Security.Providers/OpenID/Provider/IOpenIDAuthenticationProvider.cs new file mode 100644 index 0000000..9012007 --- /dev/null +++ b/Owin.Security.Providers/OpenID/Provider/IOpenIDAuthenticationProvider.cs @@ -0,0 +1,24 @@ +using System.Threading.Tasks; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Specifies callback methods which the invokes to enable developer control over the authentication process. /> + /// + public interface IOpenIDAuthenticationProvider + { + /// + /// Invoked whenever OpenID succesfully authenticates a user + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + Task Authenticated(OpenIDAuthenticatedContext context); + + /// + /// Invoked prior to the being saved in a local cookie and the browser being redirected to the originally requested URL. + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + Task ReturnEndpoint(OpenIDReturnEndpointContext context); + } +} diff --git a/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticatedContext.cs b/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticatedContext.cs new file mode 100644 index 0000000..f83120c --- /dev/null +++ b/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticatedContext.cs @@ -0,0 +1,51 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Provider; +using System.Collections.Generic; +using System.Security.Claims; +using System.Xml.Linq; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Contains information about the login session as well as the user . + /// + public class OpenIDAuthenticatedContext : BaseContext + { + /// + /// Initializes a + /// + /// The OWIN environment + /// The representing the user + /// A property bag for common authentication properties + /// + /// + public OpenIDAuthenticatedContext( + IOwinContext context, + ClaimsIdentity identity, + AuthenticationProperties properties, + XElement responseMessage, + IDictionary attributeExchangeProperties) + : base(context) + { + Identity = identity; + Properties = properties; + ResponseMessage = responseMessage; + AttributeExchangeProperties = attributeExchangeProperties; + } + + /// + /// Gets or sets the representing the user + /// + public ClaimsIdentity Identity { get; set; } + + /// + /// Gets or sets a property bag for common authentication properties + /// + public AuthenticationProperties Properties { get; set; } + + public XElement ResponseMessage { get; set; } + + public IDictionary AttributeExchangeProperties { get; private set; } + } +} diff --git a/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticationProvider.cs b/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticationProvider.cs new file mode 100644 index 0000000..0b78c74 --- /dev/null +++ b/Owin.Security.Providers/OpenID/Provider/OpenIDAuthenticationProvider.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Default implementation. + /// + public class OpenIDAuthenticationProvider : IOpenIDAuthenticationProvider + { + /// + /// Initializes a + /// + public OpenIDAuthenticationProvider() + { + OnAuthenticated = context => Task.FromResult(null); + OnReturnEndpoint = context => Task.FromResult(null); + } + + /// + /// Gets or sets the function that is invoked when the Authenticated method is invoked. + /// + public Func OnAuthenticated { get; set; } + + /// + /// Gets or sets the function that is invoked when the ReturnEndpoint method is invoked. + /// + public Func OnReturnEndpoint { get; set; } + + /// + /// Invoked whenever OpenID succesfully authenticates a user + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + public virtual Task Authenticated(OpenIDAuthenticatedContext context) + { + return OnAuthenticated(context); + } + + /// + /// Invoked prior to the being saved in a local cookie and the browser being redirected to the originally requested URL. + /// + /// Contains information about the login session as well as the user . + /// A representing the completed operation. + public virtual Task ReturnEndpoint(OpenIDReturnEndpointContext context) + { + return OnReturnEndpoint(context); + } + } +} diff --git a/Owin.Security.Providers/OpenID/Provider/OpenIDReturnEndpointContext.cs b/Owin.Security.Providers/OpenID/Provider/OpenIDReturnEndpointContext.cs new file mode 100644 index 0000000..027abe5 --- /dev/null +++ b/Owin.Security.Providers/OpenID/Provider/OpenIDReturnEndpointContext.cs @@ -0,0 +1,21 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Provider; + +namespace Owin.Security.Providers.OpenID +{ + /// + /// Provides context information to middleware providers. + /// + public class OpenIDReturnEndpointContext : ReturnEndpointContext + { + /// + /// Initializes a + /// + /// OWIN environment + /// The authentication ticket + public OpenIDReturnEndpointContext(IOwinContext context, AuthenticationTicket ticket) + : base(context, ticket) + { } + } +} diff --git a/Owin.Security.Providers/Owin.Security.Providers.csproj b/Owin.Security.Providers/Owin.Security.Providers.csproj index fc19839..f91a14e 100644 --- a/Owin.Security.Providers/Owin.Security.Providers.csproj +++ b/Owin.Security.Providers/Owin.Security.Providers.csproj @@ -73,12 +73,27 @@ + + + + + + + + + + + True True Resources.resx + + + + @@ -102,6 +117,7 @@ + - + diff --git a/README.md b/README.md index d61ff5f..15401af 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,8 @@ OwinOAuthProviders Authentication providers for OWIN (Katana). Includes OAuth providers for: - Yahoo - LinkedIn +- GitHub +- OpenID 2.0 providers +- Steam For more details on how to use these your can view [this blog post](http://www.beabigrockstar.com/introducing-the-yahoo-linkedin-oauth-security-providers-for-owin)