diff --git a/Owin.Security.Providers/Foursquare/Constants.cs b/Owin.Security.Providers/Foursquare/Constants.cs new file mode 100644 index 0000000..e40a196 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/Constants.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Owin.Security.Providers.Foursquare +{ + internal static class Constants + { + internal const String DefaultAuthenticationType = "Foursquare"; + } +} diff --git a/Owin.Security.Providers/Foursquare/FoursquareAuthenticationExtensions.cs b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationExtensions.cs new file mode 100644 index 0000000..f098035 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationExtensions.cs @@ -0,0 +1,32 @@ +using System; +using Owin; + +namespace Owin.Security.Providers.Foursquare +{ + public static class FoursquareAuthenticationExtensions + { + public static IAppBuilder UseFoursquareAuthentication(this IAppBuilder app, FoursquareAuthenticationOptions options) + { + if (app == null) + { + throw new ArgumentNullException("app"); + } + + if (options == null) + { + throw new ArgumentNullException("options"); + } + + return app.Use(typeof(FoursquareAuthenticationMiddleware), app, options); + } + + public static IAppBuilder UseFoursquareAuthentication(this IAppBuilder app, String clientId, String clientSecret) + { + return app.UseFoursquareAuthentication(new FoursquareAuthenticationOptions + { + ClientId = clientId, + ClientSecret = clientSecret + }); + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/FoursquareAuthenticationHandler.cs b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationHandler.cs new file mode 100644 index 0000000..c25a748 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationHandler.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Owin.Infrastructure; +using Microsoft.Owin.Logging; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Infrastructure; +using Newtonsoft.Json.Linq; +using Owin.Security.Providers.Foursquare.Provider; + +namespace Owin.Security.Providers.Foursquare +{ + public class FoursquareAuthenticationHandler : AuthenticationHandler + { + private const String AuthorizationEndpoint = "https://foursquare.com/oauth2/authenticate"; + private const String TokenEndpoint = "https://foursquare.com/oauth2/access_token"; + private const String GraphApiEndpoint = "https://api.foursquare.com/v2/users/self"; + private const String XmlSchemaString = "http://www.w3.org/2001/XMLSchema#string"; + + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public FoursquareAuthenticationHandler(HttpClient httpClient, ILogger logger) + { + this._httpClient = httpClient; + this._logger = logger; + } + + public override async Task InvokeAsync() + { + if ((String.IsNullOrEmpty(this.Options.CallbackPath) == false) && (this.Options.CallbackPath == this.Request.Path.ToString())) + { + return await this.InvokeReturnPathAsync(); + } + + return false; + } + + protected override async Task AuthenticateCoreAsync() + { + this._logger.WriteVerbose("AuthenticateCore"); + + AuthenticationProperties properties = null; + + try + { + String code = null; + String state = null; + + var query = this.Request.Query; + var values = query.GetValues("code"); + + if ((values != null) && (values.Count == 1)) + { + code = values[0]; + } + + values = query.GetValues("state"); + + if ((values != null) && (values.Count == 1)) + { + state = values[0]; + } + + properties = this.Options.StateDataFormat.Unprotect(state); + + if (properties == null) + { + return null; + } + + // OAuth2 10.12 CSRF + if (this.ValidateCorrelationId(properties, this._logger) == false) + { + return new AuthenticationTicket(null, properties); + } + + var tokenRequestParameters = new List>() + { + new KeyValuePair("client_id", this.Options.ClientId), + new KeyValuePair("client_secret", this.Options.ClientSecret), + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("redirect_uri", this.GenerateRedirectUri()), + new KeyValuePair("code", code), + }; + + var requestContent = new FormUrlEncodedContent(tokenRequestParameters); + + var response = await this._httpClient.PostAsync(TokenEndpoint, requestContent, this.Request.CallCancelled); + response.EnsureSuccessStatusCode(); + + var oauthTokenResponse = await response.Content.ReadAsStringAsync(); + + var oauth2Token = JObject.Parse(oauthTokenResponse); + var accessToken = oauth2Token["access_token"].Value(); + + if (String.IsNullOrWhiteSpace(accessToken) == true) + { + this._logger.WriteWarning("Access token was not found"); + return new AuthenticationTicket(null, properties); + } + + var graphResponse = await this._httpClient.GetAsync(GraphApiEndpoint + "?oauth_token=" + Uri.EscapeDataString(accessToken), Request.CallCancelled); + graphResponse.EnsureSuccessStatusCode(); + + var accountString = await graphResponse.Content.ReadAsStringAsync(); + var accountInformation = JObject.Parse(accountString); + var user = (JObject) accountInformation["response"]["user"]; + + var context = new FoursquareAuthenticatedContext(this.Context, user, accessToken); + + context.Identity = new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, context.Id, XmlSchemaString, this.Options.AuthenticationType), + new Claim(ClaimTypes.Name, context.Name, XmlSchemaString, this.Options.AuthenticationType), + new Claim("urn:foursquare:id", context.Id, XmlSchemaString, this.Options.AuthenticationType), + new Claim("urn:foursquare:name", context.Name, XmlSchemaString, this.Options.AuthenticationType) + }, + this.Options.AuthenticationType, + ClaimsIdentity.DefaultNameClaimType, + ClaimsIdentity.DefaultRoleClaimType); + + if (String.IsNullOrWhiteSpace(context.Email) == false) + { + context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, XmlSchemaString, this.Options.AuthenticationType)); + } + + await this.Options.Provider.Authenticated(context); + + context.Properties = properties; + + return new AuthenticationTicket(context.Identity, context.Properties); + } + catch (Exception ex) + { + this._logger.WriteWarning("Authentication failed", ex); + return new AuthenticationTicket(null, properties); + } + } + + protected override Task ApplyResponseChallengeAsync() + { + this._logger.WriteVerbose("ApplyResponseChallenge"); + + if (this.Response.StatusCode != (Int32) HttpStatusCode.Unauthorized) + { + return Task.FromResult(null); + } + + var challenge = Helper.LookupChallenge(this.Options.AuthenticationType, this.Options.AuthenticationMode); + + if (challenge != null) + { + var baseUri = this.Request.Scheme + Uri.SchemeDelimiter + this.Request.Host + this.Request.PathBase; + var currentUri = baseUri + this.Request.Path + this.Request.QueryString; + var redirectUri = baseUri + this.Options.CallbackPath; + + var extra = challenge.Properties; + + if (String.IsNullOrEmpty(extra.RedirectUri) == true) + { + extra.RedirectUri = currentUri; + } + + // OAuth2 10.12 CSRF + this.GenerateCorrelationId(extra); + + // OAuth2 3.3 space separated + var scope = String.Join(" ", this.Options.Scope); + + var state = this.Options.StateDataFormat.Protect(extra); + + var authorizationEndpoint = AuthorizationEndpoint + + "?client_id=" + Uri.EscapeDataString(this.Options.ClientId) + + "&response_type=code" + + "&redirect_uri=" + Uri.EscapeDataString(redirectUri) + + "&state=" + Uri.EscapeDataString(state); + + this.Response.StatusCode = (Int32) HttpStatusCode.Moved; + this.Response.Headers.Set("Location", authorizationEndpoint); + } + + return Task.FromResult(null); + } + + public async Task InvokeReturnPathAsync() + { + this._logger.WriteVerbose("InvokeReturnPath"); + + var model = await this.AuthenticateAsync(); + + var context = new FoursquareReturnEndpointContext(Context, model); + context.SignInAsAuthenticationType = this.Options.SignInAsAuthenticationType; + context.RedirectUri = model.Properties.RedirectUri; + + model.Properties.RedirectUri = null; + + await this.Options.Provider.ReturnEndpoint(context); + + if ((context.SignInAsAuthenticationType != null) && (context.Identity != null)) + { + var signInIdentity = context.Identity; + + if (String.Equals(signInIdentity.AuthenticationType, context.SignInAsAuthenticationType, StringComparison.Ordinal) == false) + { + signInIdentity = new ClaimsIdentity(signInIdentity.Claims, context.SignInAsAuthenticationType, signInIdentity.NameClaimType, signInIdentity.RoleClaimType); + } + + this.Context.Authentication.SignIn(context.Properties, signInIdentity); + } + + if ((context.IsRequestCompleted == false) && (context.RedirectUri != null)) + { + if (context.Identity == null) + { + context.RedirectUri = WebUtilities.AddQueryString(context.RedirectUri, "error", "access_denied"); + } + + this.Response.Redirect(context.RedirectUri); + + context.RequestCompleted(); + } + + return context.IsRequestCompleted; + } + + private String GenerateRedirectUri() + { + var requestPrefix = this.Request.Scheme + "://" + this.Request.Host; + var redirectUri = requestPrefix + this.RequestPathBase + this.Options.CallbackPath; + return redirectUri; + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/FoursquareAuthenticationMiddleware.cs b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationMiddleware.cs new file mode 100644 index 0000000..33c8350 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationMiddleware.cs @@ -0,0 +1,81 @@ +using System; +using System.Net.Http; +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; +using Owin.Security.Providers.Foursquare.Provider; + +namespace Owin.Security.Providers.Foursquare +{ + public class FoursquareAuthenticationMiddleware : AuthenticationMiddleware + { + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + public FoursquareAuthenticationMiddleware(OwinMiddleware next, IAppBuilder app, FoursquareAuthenticationOptions options) : base(next, options) + { + if (String.IsNullOrWhiteSpace(this.Options.ClientId) == true) + { + throw new ArgumentException("The 'ClientId' must be provided."); + } + + if (String.IsNullOrWhiteSpace(this.Options.ClientSecret) == true) + { + throw new ArgumentException("The 'ClientSecret' option must be provided."); + } + + this._logger = app.CreateLogger(); + + if (this.Options.Provider == null) + { + this.Options.Provider = new FoursquareAuthenticationProvider(); + } + + if (this.Options.StateDataFormat == null) + { + var dataProtector = app.CreateDataProtector(typeof(FoursquareAuthenticationMiddleware).FullName, this.Options.AuthenticationType, "v1"); + this.Options.StateDataFormat = new PropertiesDataFormat(dataProtector); + } + + if (String.IsNullOrEmpty(this.Options.SignInAsAuthenticationType) == true) + { + this.Options.SignInAsAuthenticationType = app.GetDefaultSignInAsAuthenticationType(); + } + + this._httpClient = new HttpClient(ResolveHttpMessageHandler(this.Options)); + this._httpClient.Timeout = this.Options.BackchannelTimeout; + this._httpClient.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB + } + + protected override AuthenticationHandler CreateHandler() + { + return new FoursquareAuthenticationHandler(this._httpClient, this._logger); + } + + private static HttpMessageHandler ResolveHttpMessageHandler(FoursquareAuthenticationOptions options) + { + var 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("Validator Handler Mismatch"); + } + + webRequestHandler.ServerCertificateValidationCallback = options.BackchannelCertificateValidator.Validate; + } + + return handler; + } + + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/FoursquareAuthenticationOptions.cs b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationOptions.cs new file mode 100644 index 0000000..1aa5863 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/FoursquareAuthenticationOptions.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using Microsoft.Owin.Security; +using Owin.Security.Providers.Foursquare.Provider; + +namespace Owin.Security.Providers.Foursquare +{ + public class FoursquareAuthenticationOptions : AuthenticationOptions + { + /// + /// Initializes a new + /// + public FoursquareAuthenticationOptions() : base(Constants.DefaultAuthenticationType) + { + this.Caption = Constants.DefaultAuthenticationType; + this.CallbackPath = "/signin-foursquare"; + this.AuthenticationMode = AuthenticationMode.Passive; + this.BackchannelTimeout = TimeSpan.FromSeconds(60); + this.Scope = new List(); + } + + /// + /// Gets or sets the Foursquare supplied Client ID + /// + public String ClientId { get; set; } + + /// + /// Gets or sets the Foursquare supplied Client Secret + /// + public String ClientSecret { get; set; } + + /// + /// Gets or sets the a pinned certificate validator to use to validate the endpoints used + /// in back channel communications belong to Foursquare. + /// + /// + /// 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 Foursquare. + /// + /// + /// The back channel timeout in milliseconds. + /// + public TimeSpan BackchannelTimeout { get; set; } + + /// + /// The HttpMessageHandler used to communicate with Foursquare. + /// This cannot be set at the same time as BackchannelCertificateValidator unless the value + /// can be downcast to a WebRequestHandler. + /// + public HttpMessageHandler BackchannelHttpHandler { get; set; } + + /// + /// 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-foursquare". + /// + public String 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 in the authentication events + /// + public IFoursquareAuthenticationProvider Provider { get; set; } + + /// + /// Gets or sets the type used to secure data handled by the middleware. + /// + public ISecureDataFormat StateDataFormat { get; set; } + + /// + /// A list of permissions to request. + /// + public IList Scope { get; private set; } + + /// + /// Get or sets the text that the user can display on a sign in user interface. + /// + public String Caption + { + get { return this.Description.Caption; } + set { this.Description.Caption = value; } + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticatedContext.cs b/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticatedContext.cs new file mode 100644 index 0000000..35d39d8 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticatedContext.cs @@ -0,0 +1,82 @@ +using System; +using System.Security.Claims; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Provider; +using Newtonsoft.Json.Linq; + +namespace Owin.Security.Providers.Foursquare.Provider +{ + public class FoursquareAuthenticatedContext : BaseContext + { + public FoursquareAuthenticatedContext(IOwinContext context, JObject user, String accessToken) : base(context) + { + if (user == null) + { + throw new ArgumentNullException("user"); + } + + this.User = user; + this.AccessToken = accessToken; + + var userId = this.User["id"]; + + if (userId == null) + { + throw new ArgumentException("The user does not have an id.", "user"); + } + + this.Id = TryGetValue(user, "id"); + this.FirstName = TryGetValue(user, "firstName"); + this.LastName = TryGetValue(user, "lastName"); + this.Name = this.FirstName + " " + this.LastName; + this.Gender = TryGetValue(user, "gender"); + this.Photo = TryGetValue(user, "photo"); + this.Friends = TryGetValue(user, "friends"); + this.HomeCity = TryGetValue(user, "homeCity"); + this.Bio = TryGetValue(user, "bio"); + this.Contact = TryGetValue(user, "contact"); + this.Phone = TryGetValue(JObject.Parse(this.Contact), "phone"); + this.Email = TryGetValue(JObject.Parse(this.Contact), "email"); + this.Twitter = TryGetValue(JObject.Parse(this.Contact), "twitter"); + this.Facebook = TryGetValue(JObject.Parse(this.Contact), "facebook"); + this.Badges = TryGetValue(user, "badges"); + this.Mayorships = TryGetValue(user, "mayorships"); + this.Checkins = TryGetValue(user, "checkins"); + this.Photos = TryGetValue(user, "photos"); + this.Scores = TryGetValue(user, "scores"); + this.Link = "https://foursquare.com/user/" + this.Id; + } + + public JObject User { get; private set; } + public String AccessToken { get; private set; } + public String Id { get; private set; } + public String FirstName { get; private set; } + public String LastName { get; private set; } + public String Name { get; private set; } + public String Gender { get; private set; } + public String Photo { get; private set; } + public String Friends { get; private set; } + public String HomeCity { get; private set; } + public String Bio { get; private set; } + public String Contact { get; private set; } + public String Phone { get; private set; } + public String Email { get; private set; } + public String Twitter { get; private set; } + public String Facebook { get; private set; } + public String Badges { get; private set; } + public String Mayorships { get; private set; } + public String Checkins { get; private set; } + public String Photos { get; private set; } + public String Scores { get; private set; } + public String Link { get; private set; } + public ClaimsIdentity Identity { get; set; } + public AuthenticationProperties Properties { get; set; } + + private static String TryGetValue(JObject user, String propertyName) + { + JToken value; + return user.TryGetValue(propertyName, out value) ? value.ToString() : null; + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticationProvider.cs b/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticationProvider.cs new file mode 100644 index 0000000..fb3a062 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/Provider/FoursquareAuthenticationProvider.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; + +namespace Owin.Security.Providers.Foursquare.Provider +{ + public class FoursquareAuthenticationProvider : IFoursquareAuthenticationProvider + { + public FoursquareAuthenticationProvider() + { + this.OnAuthenticated = context => Task.FromResult(null); + this.OnReturnEndpoint = context => Task.FromResult(null); + } + + public Func OnAuthenticated { get; set; } + + public Func OnReturnEndpoint { get; set; } + + public virtual Task Authenticated(FoursquareAuthenticatedContext context) + { + return this.OnAuthenticated(context); + } + + public virtual Task ReturnEndpoint(FoursquareReturnEndpointContext context) + { + return this.OnReturnEndpoint(context); + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/Provider/FoursquareReturnEndpointContext.cs b/Owin.Security.Providers/Foursquare/Provider/FoursquareReturnEndpointContext.cs new file mode 100644 index 0000000..fec043a --- /dev/null +++ b/Owin.Security.Providers/Foursquare/Provider/FoursquareReturnEndpointContext.cs @@ -0,0 +1,18 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Provider; + +namespace Owin.Security.Providers.Foursquare.Provider +{ + public class FoursquareReturnEndpointContext : ReturnEndpointContext + { + /// + /// + /// + /// OWIN environment + /// The authentication ticket + public FoursquareReturnEndpointContext(IOwinContext context, AuthenticationTicket ticket) : base(context, ticket) + { + } + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Foursquare/Provider/IFoursquareAuthenticationProvider.cs b/Owin.Security.Providers/Foursquare/Provider/IFoursquareAuthenticationProvider.cs new file mode 100644 index 0000000..0403897 --- /dev/null +++ b/Owin.Security.Providers/Foursquare/Provider/IFoursquareAuthenticationProvider.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Owin.Security.Providers.Foursquare.Provider +{ + public interface IFoursquareAuthenticationProvider + { + Task Authenticated(FoursquareAuthenticatedContext context); + + Task ReturnEndpoint(FoursquareReturnEndpointContext context); + } +} \ No newline at end of file diff --git a/Owin.Security.Providers/Owin.Security.Providers.csproj b/Owin.Security.Providers/Owin.Security.Providers.csproj index 2cb1017..a638670 100644 --- a/Owin.Security.Providers/Owin.Security.Providers.csproj +++ b/Owin.Security.Providers/Owin.Security.Providers.csproj @@ -112,6 +112,15 @@ + + + + + + + + +