From 0bce133ee4c267484e36cc98f9ccf25f698761d7 Mon Sep 17 00:00:00 2001 From: Chris R Date: Thu, 28 Apr 2016 10:18:01 -0700 Subject: [PATCH] #765 Retrieve the email address from Twitter. --- samples/SocialSample/Startup.cs | 19 +++- samples/SocialSample/config.json | 4 +- .../Events/TwitterCreatingTicketContext.cs | 20 +++- .../LoggingExtensions.cs | 10 ++ .../TwitterHandler.cs | 102 +++++++++++++++--- .../TwitterOptions.cs | 8 ++ .../project.json | 9 +- 7 files changed, 150 insertions(+), 22 deletions(-) diff --git a/samples/SocialSample/Startup.cs b/samples/SocialSample/Startup.cs index bf09b3f6f2..474e43ae75 100644 --- a/samples/SocialSample/Startup.cs +++ b/samples/SocialSample/Startup.cs @@ -125,9 +125,18 @@ namespace SocialSample { ConsumerKey = Configuration["twitter:consumerkey"], ConsumerSecret = Configuration["twitter:consumersecret"], + // http://stackoverflow.com/questions/22627083/can-we-get-email-id-from-twitter-oauth-api/32852370#32852370 + // http://stackoverflow.com/questions/36330675/get-users-email-from-twitter-api-for-external-login-authentication-asp-net-mvc?lq=1 + RetrieveUserDetails = true, SaveTokens = true, Events = new TwitterEvents() { + OnCreatingTicket = ctx => + { + var profilePic = ctx.User.Value("profile_image_url"); + ctx.Principal.Identities.First().AddClaim(new Claim("urn:twitter:profilepicture", profilePic, ClaimTypes.Uri, ctx.Options.ClaimsIssuer)); + return Task.FromResult(0); + }, OnRemoteFailure = ctx => { ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message)); @@ -139,7 +148,7 @@ namespace SocialSample /* Azure AD app model v2 has restrictions that prevent the use of plain HTTP for redirect URLs. Therefore, to authenticate through microsoft accounts, tryout the sample using the following URL: - https://localhost:54541/ + https://localhost:44318/ */ // See config.json // https://apps.dev.microsoft.com/ @@ -232,6 +241,14 @@ namespace SocialSample ClaimValueTypes.String, context.Options.ClaimsIssuer)); } + var email = user.Value("email"); + if (!string.IsNullOrEmpty(email)) + { + context.Identity.AddClaim(new Claim( + ClaimTypes.Email, email, + ClaimValueTypes.Email, context.Options.ClaimsIssuer)); + } + var link = user.Value("url"); if (!string.IsNullOrEmpty(link)) { diff --git a/samples/SocialSample/config.json b/samples/SocialSample/config.json index 5c1453e39f..11477998c0 100644 --- a/samples/SocialSample/config.json +++ b/samples/SocialSample/config.json @@ -1,8 +1,8 @@ { "google:clientid": "560027070069-37ldt4kfuohhu3m495hk2j4pjp92d382.apps.googleusercontent.com", "google:clientsecret": "n2Q-GEw9RQjzcRbU3qhfTj8f", - "twitter:consumerkey": "6XaCTaLbMqfj6ww3zvZ5g", - "twitter:consumersecret": "Il2eFzGIrYhz6BWjYhVXBPQSfZuS4xoHpSSyD9PI", + "twitter:consumerkey": "VvNJRyGeqYBByN694UHudI2cv", + "twitter:consumersecret": "V2xEqWgmphPdlUXX4ARWsozl9lfbvr5wbAYw2LN8m6kZV7pt20", "github:clientid": "49e302895d8b09ea5656", "github:clientsecret": "98f1bf028608901e9df91d64ee61536fe562064b", "github-token:clientid": "8c0c5a572abe8fe89588", diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/Events/TwitterCreatingTicketContext.cs b/src/Microsoft.AspNetCore.Authentication.Twitter/Events/TwitterCreatingTicketContext.cs index 04a45ac6d8..435196a1e5 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/Events/TwitterCreatingTicketContext.cs +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/Events/TwitterCreatingTicketContext.cs @@ -5,6 +5,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Authentication; +using Newtonsoft.Json.Linq; namespace Microsoft.AspNetCore.Authentication.Twitter { @@ -22,40 +23,49 @@ namespace Microsoft.AspNetCore.Authentication.Twitter /// Twitter screen name /// Twitter access token /// Twitter access token secret + /// User details public TwitterCreatingTicketContext( HttpContext context, TwitterOptions options, string userId, string screenName, string accessToken, - string accessTokenSecret) + string accessTokenSecret, + JObject user) : base(context, options) { UserId = userId; ScreenName = screenName; AccessToken = accessToken; AccessTokenSecret = accessTokenSecret; + User = user ?? new JObject(); } /// /// Gets the Twitter user ID /// - public string UserId { get; private set; } + public string UserId { get; } /// /// Gets the Twitter screen name /// - public string ScreenName { get; private set; } + public string ScreenName { get; } /// /// Gets the Twitter access token /// - public string AccessToken { get; private set; } + public string AccessToken { get; } /// /// Gets the Twitter access token secret /// - public string AccessTokenSecret { get; private set; } + public string AccessTokenSecret { get; } + + /// + /// Gets the JSON-serialized user or an empty + /// if it is not available. + /// + public JObject User { get; } /// /// Gets the representing the user diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/LoggingExtensions.cs b/src/Microsoft.AspNetCore.Authentication.Twitter/LoggingExtensions.cs index 21a4ac541d..2a2cd5da79 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/LoggingExtensions.cs +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/LoggingExtensions.cs @@ -9,6 +9,7 @@ namespace Microsoft.Extensions.Logging { private static Action _obtainRequestToken; private static Action _obtainAccessToken; + private static Action _retrieveUserDetails; static LoggingExtensions() { @@ -20,6 +21,10 @@ namespace Microsoft.Extensions.Logging eventId: 2, logLevel: LogLevel.Debug, formatString: "ObtainAccessToken"); + _retrieveUserDetails = LoggerMessage.Define( + eventId: 3, + logLevel: LogLevel.Debug, + formatString: "RetrieveUserDetails"); } @@ -32,5 +37,10 @@ namespace Microsoft.Extensions.Logging { _obtainRequestToken(logger, null); } + + public static void RetrieveUserDetails(this ILogger logger) + { + _retrieveUserDetails(logger, null); + } } } diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterHandler.cs b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterHandler.cs index fdb7e3e077..4fbf35aaa1 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterHandler.cs +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterHandler.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Newtonsoft.Json.Linq; namespace Microsoft.AspNetCore.Authentication.Twitter { @@ -76,7 +77,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter Response.Cookies.Delete(StateCookie, cookieOptions); - var accessToken = await ObtainAccessTokenAsync(Options.ConsumerKey, Options.ConsumerSecret, requestToken, oauthVerifier); + var accessToken = await ObtainAccessTokenAsync(requestToken, oauthVerifier); var identity = new ClaimsIdentity(new[] { @@ -87,6 +88,12 @@ namespace Microsoft.AspNetCore.Authentication.Twitter }, Options.ClaimsIssuer); + JObject user = null; + if (Options.RetrieveUserDetails) + { + user = await RetrieveUserDetailsAsync(accessToken, identity); + } + if (Options.SaveTokens) { properties.StoreTokens(new [] { @@ -95,12 +102,13 @@ namespace Microsoft.AspNetCore.Authentication.Twitter }); } - return AuthenticateResult.Success(await CreateTicketAsync(identity, properties, accessToken)); + return AuthenticateResult.Success(await CreateTicketAsync(identity, properties, accessToken, user)); } - protected virtual async Task CreateTicketAsync(ClaimsIdentity identity, AuthenticationProperties properties, AccessToken token) + protected virtual async Task CreateTicketAsync( + ClaimsIdentity identity, AuthenticationProperties properties, AccessToken token, JObject user) { - var context = new TwitterCreatingTicketContext(Context, Options, token.UserId, token.ScreenName, token.Token, token.TokenSecret) + var context = new TwitterCreatingTicketContext(Context, Options, token.UserId, token.ScreenName, token.Token, token.TokenSecret, user) { Principal = new ClaimsPrincipal(identity), Properties = properties @@ -134,7 +142,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter } // If CallbackConfirmed is false, this will throw - var requestToken = await ObtainRequestTokenAsync(Options.ConsumerKey, Options.ConsumerSecret, BuildRedirectUri(Options.CallbackPath), properties); + var requestToken = await ObtainRequestTokenAsync(BuildRedirectUri(Options.CallbackPath), properties); var twitterAuthenticationEndpoint = AuthenticationEndpoint + requestToken.Token; var cookieOptions = new CookieOptions @@ -152,7 +160,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter return true; } - private async Task ObtainRequestTokenAsync(string consumerKey, string consumerSecret, string callBackUri, AuthenticationProperties properties) + private async Task ObtainRequestTokenAsync(string callBackUri, AuthenticationProperties properties) { Logger.ObtainRequestToken(); @@ -161,7 +169,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter var authorizationParts = new SortedDictionary { { "oauth_callback", callBackUri }, - { "oauth_consumer_key", consumerKey }, + { "oauth_consumer_key", Options.ConsumerKey }, { "oauth_nonce", nonce }, { "oauth_signature_method", "HMAC-SHA1" }, { "oauth_timestamp", GenerateTimeStamp() }, @@ -183,7 +191,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter canonicalizedRequestBuilder.Append("&"); canonicalizedRequestBuilder.Append(UrlEncoder.Encode(parameterString)); - var signature = ComputeSignature(consumerSecret, null, canonicalizedRequestBuilder.ToString()); + var signature = ComputeSignature(Options.ConsumerSecret, null, canonicalizedRequestBuilder.ToString()); authorizationParts.Add("oauth_signature", signature); var authorizationHeaderBuilder = new StringBuilder(); @@ -200,7 +208,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter var response = await _httpClient.SendAsync(request, Context.RequestAborted); response.EnsureSuccessStatusCode(); - string responseText = await response.Content.ReadAsStringAsync(); + var responseText = await response.Content.ReadAsStringAsync(); var responseParameters = new FormCollection(new FormReader(responseText).ReadForm()); if (!string.Equals(responseParameters["oauth_callback_confirmed"], "true", StringComparison.Ordinal)) @@ -211,7 +219,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter return new RequestToken { Token = Uri.UnescapeDataString(responseParameters["oauth_token"]), TokenSecret = Uri.UnescapeDataString(responseParameters["oauth_token_secret"]), CallbackConfirmed = true, Properties = properties }; } - private async Task ObtainAccessTokenAsync(string consumerKey, string consumerSecret, RequestToken token, string verifier) + private async Task ObtainAccessTokenAsync(RequestToken token, string verifier) { // https://dev.twitter.com/docs/api/1/post/oauth/access_token @@ -221,7 +229,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter var authorizationParts = new SortedDictionary { - { "oauth_consumer_key", consumerKey }, + { "oauth_consumer_key", Options.ConsumerKey }, { "oauth_nonce", nonce }, { "oauth_signature_method", "HMAC-SHA1" }, { "oauth_token", token.Token }, @@ -245,7 +253,7 @@ namespace Microsoft.AspNetCore.Authentication.Twitter canonicalizedRequestBuilder.Append("&"); canonicalizedRequestBuilder.Append(UrlEncoder.Encode(parameterString)); - var signature = ComputeSignature(consumerSecret, token.TokenSecret, canonicalizedRequestBuilder.ToString()); + var signature = ComputeSignature(Options.ConsumerSecret, token.TokenSecret, canonicalizedRequestBuilder.ToString()); authorizationParts.Add("oauth_signature", signature); authorizationParts.Remove("oauth_verifier"); @@ -288,6 +296,76 @@ namespace Microsoft.AspNetCore.Authentication.Twitter }; } + // https://dev.twitter.com/rest/reference/get/account/verify_credentials + private async Task RetrieveUserDetailsAsync(AccessToken accessToken, ClaimsIdentity identity) + { + Logger.RetrieveUserDetails(); + + var nonce = Guid.NewGuid().ToString("N"); + + var authorizationParts = new SortedDictionary + { + { "oauth_consumer_key", Options.ConsumerKey }, + { "oauth_nonce", nonce }, + { "oauth_signature_method", "HMAC-SHA1" }, + { "oauth_timestamp", GenerateTimeStamp() }, + { "oauth_token", accessToken.Token }, + { "oauth_version", "1.0" } + }; + + var parameterBuilder = new StringBuilder(); + foreach (var authorizationKey in authorizationParts) + { + parameterBuilder.AppendFormat("{0}={1}&", UrlEncoder.Encode(authorizationKey.Key), UrlEncoder.Encode(authorizationKey.Value)); + } + parameterBuilder.Length--; + var parameterString = parameterBuilder.ToString(); + + var resource_url = "https://api.twitter.com/1.1/account/verify_credentials.json"; + var resource_query = "include_email=true"; + var canonicalizedRequestBuilder = new StringBuilder(); + canonicalizedRequestBuilder.Append(HttpMethod.Get.Method); + canonicalizedRequestBuilder.Append("&"); + canonicalizedRequestBuilder.Append(UrlEncoder.Encode(resource_url)); + canonicalizedRequestBuilder.Append("&"); + canonicalizedRequestBuilder.Append(UrlEncoder.Encode(resource_query)); + canonicalizedRequestBuilder.Append("%26"); + canonicalizedRequestBuilder.Append(UrlEncoder.Encode(parameterString)); + + var signature = ComputeSignature(Options.ConsumerSecret, accessToken.TokenSecret, canonicalizedRequestBuilder.ToString()); + authorizationParts.Add("oauth_signature", signature); + + var authorizationHeaderBuilder = new StringBuilder(); + authorizationHeaderBuilder.Append("OAuth "); + foreach (var authorizationPart in authorizationParts) + { + authorizationHeaderBuilder.AppendFormat( + "{0}=\"{1}\", ", authorizationPart.Key, UrlEncoder.Encode(authorizationPart.Value)); + } + authorizationHeaderBuilder.Length = authorizationHeaderBuilder.Length - 2; + + var request = new HttpRequestMessage(HttpMethod.Get, resource_url + "?include_email=true"); + request.Headers.Add("Authorization", authorizationHeaderBuilder.ToString()); + + var response = await _httpClient.SendAsync(request, Context.RequestAborted); + if (!response.IsSuccessStatusCode) + { + Logger.LogError("Email request failed with a status code of " + response.StatusCode); + response.EnsureSuccessStatusCode(); // throw + } + var responseText = await response.Content.ReadAsStringAsync(); + + var result = JObject.Parse(responseText); + + var email = result.Value("email"); + if (!string.IsNullOrEmpty(email)) + { + identity.AddClaim(new Claim(ClaimTypes.Email, email, ClaimValueTypes.Email, Options.ClaimsIssuer)); + } + + return result; + } + private static string GenerateTimeStamp() { var secondsSinceUnixEpocStart = DateTime.UtcNow - Epoch; diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs index 77fb0bd7a8..8ab399a8f9 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/TwitterOptions.cs @@ -38,6 +38,14 @@ namespace Microsoft.AspNetCore.Builder /// The consumer secret used to sign requests to Twitter. public string ConsumerSecret { get; set; } + /// + /// Enables the retrieval user details during the authentication process, including + /// e-mail addresses. Retrieving e-mail addresses requires special permissions + /// from Twitter Support on a per application basis. The default is false. + /// See https://dev.twitter.com/rest/reference/get/account/verify_credentials + /// + public bool RetrieveUserDetails { get; set; } + /// /// Gets or sets the type used to secure data handled by the middleware. /// diff --git a/src/Microsoft.AspNetCore.Authentication.Twitter/project.json b/src/Microsoft.AspNetCore.Authentication.Twitter/project.json index 23ca0cf313..a185aa415a 100644 --- a/src/Microsoft.AspNetCore.Authentication.Twitter/project.json +++ b/src/Microsoft.AspNetCore.Authentication.Twitter/project.json @@ -19,10 +19,15 @@ "xmlDoc": true }, "dependencies": { - "Microsoft.AspNetCore.Authentication": "1.0.0-*" + "Microsoft.AspNetCore.Authentication": "1.0.0-*", + "Newtonsoft.Json": "8.0.3" }, "frameworks": { "net451": {}, - "netstandard1.3": {} + "netstandard1.3": { + "imports": [ + "portable-net451+win8" + ] + } } } \ No newline at end of file