From 6948df14f6577b5729a31772faa922066531eb48 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 18 Apr 2015 00:06:57 +0200 Subject: [PATCH] OAuth 2 fixes for Box & Picasa. Noticed that Box uses one refresh-token per authentication token, as soon as an Authenticating token is requested the refresh token is used. An refresh token can also expire, usually after 60 days. This commit should make it work. --- GreenshotBoxPlugin/BoxConfiguration.cs | 6 - GreenshotBoxPlugin/BoxUtils.cs | 11 +- GreenshotPicasaPlugin/PicasaUtils.cs | 7 +- GreenshotPlugin/Controls/OAuthLoginForm.cs | 14 +- GreenshotPlugin/Core/NetworkHelper.cs | 29 +++-- GreenshotPlugin/Core/OAuthHelper.cs | 143 +++++++++++++++------ 6 files changed, 144 insertions(+), 66 deletions(-) diff --git a/GreenshotBoxPlugin/BoxConfiguration.cs b/GreenshotBoxPlugin/BoxConfiguration.cs index b7632f865..b75a3c2ae 100644 --- a/GreenshotBoxPlugin/BoxConfiguration.cs +++ b/GreenshotBoxPlugin/BoxConfiguration.cs @@ -50,12 +50,6 @@ namespace GreenshotBoxPlugin { set; } - [IniProperty("AddFilename", Description = "Is the filename passed on to Box", DefaultValue = "False")] - public bool AddFilename { - get; - set; - } - [IniProperty("RefreshToken", Description = "Box authorization refresh Token", Encrypted = true)] public string RefreshToken { get; diff --git a/GreenshotBoxPlugin/BoxUtils.cs b/GreenshotBoxPlugin/BoxUtils.cs index 2cb343c06..e0e1566d5 100644 --- a/GreenshotBoxPlugin/BoxUtils.cs +++ b/GreenshotBoxPlugin/BoxUtils.cs @@ -68,8 +68,8 @@ namespace GreenshotBoxPlugin { // Fill the OAuth2Settings OAuth2Settings settings = new OAuth2Settings(); - settings.AuthUrlPattern = "https://www.box.com/api/oauth2/authorize?client_id={ClientId}&response_type={response_type}&state{State}&redirect_uri={RedirectUrl}"; - settings.TokenUrlPattern = "https://www.box.com/api/oauth2/token"; + settings.AuthUrlPattern = "https://app.box.com/api/oauth2/authorize?client_id={ClientId}&response_type=code&state={State}&redirect_uri={RedirectUrl}"; + settings.TokenUrl = "https://api.box.com/oauth2/token"; settings.CloudServiceName = "Box"; settings.ClientId = BoxCredentials.ClientId; settings.ClientSecret = BoxCredentials.ClientSecret; @@ -83,11 +83,9 @@ namespace GreenshotBoxPlugin { settings.AccessTokenExpires = Config.AccessTokenExpires; try { - var webRequest = OAuth2Helper.CreateOAuth2WebRequest(HTTPMethod.POST, FilesUri, settings); + var webRequest = OAuth2Helper.CreateOAuth2WebRequest(HTTPMethod.POST, UploadFileUri, settings); IDictionary parameters = new Dictionary(); - if (Config.AddFilename) { - parameters.Add("filename", image); - } + parameters.Add("file", image); parameters.Add("parent_id", Config.FolderId); NetworkHelper.WriteMultipartFormData(webRequest, parameters); @@ -111,6 +109,7 @@ namespace GreenshotBoxPlugin { Config.AccessToken = settings.AccessToken; Config.AccessTokenExpires = settings.AccessTokenExpires; Config.IsDirty = true; + IniConfig.Save(); } } } diff --git a/GreenshotPicasaPlugin/PicasaUtils.cs b/GreenshotPicasaPlugin/PicasaUtils.cs index 8ea4d57c6..14c8307d1 100644 --- a/GreenshotPicasaPlugin/PicasaUtils.cs +++ b/GreenshotPicasaPlugin/PicasaUtils.cs @@ -33,7 +33,7 @@ namespace GreenshotPicasaPlugin { private const string PicasaScope = "https://picasaweb.google.com/data/"; private static readonly log4net.ILog LOG = log4net.LogManager.GetLogger(typeof(PicasaUtils)); private static readonly PicasaConfiguration Config = IniConfig.GetIniSection(); - private const string AuthUrl = "https://accounts.google.com/o/oauth2/auth?response_type={response_type}&client_id={ClientId}&redirect_uri={RedirectUrl}&state={State}&scope={scope}"; + private const string AuthUrl = "https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={ClientId}&redirect_uri={RedirectUrl}&state={State}&scope=" + PicasaScope; private const string TokenUrl = "https://www.googleapis.com/oauth2/v3/token"; private const string UploadUrl = "https://picasaweb.google.com/data/feed/api/user/{0}/albumid/{1}"; @@ -49,10 +49,8 @@ namespace GreenshotPicasaPlugin { // Fill the OAuth2Settings OAuth2Settings settings = new OAuth2Settings(); settings.AuthUrlPattern = AuthUrl; - settings.TokenUrlPattern = TokenUrl; + settings.TokenUrl = TokenUrl; settings.CloudServiceName = "Picasa"; - settings.AdditionalAttributes.Add("response_type", "code"); - settings.AdditionalAttributes.Add("scope", PicasaScope); settings.ClientId = PicasaCredentials.ClientId; settings.ClientSecret = PicasaCredentials.ClientSecret; settings.AuthorizeMode = OAuth2AuthorizeMode.LocalServer; @@ -79,6 +77,7 @@ namespace GreenshotPicasaPlugin { Config.AccessToken = settings.AccessToken; Config.AccessTokenExpires = settings.AccessTokenExpires; Config.IsDirty = true; + IniConfig.Save(); } } diff --git a/GreenshotPlugin/Controls/OAuthLoginForm.cs b/GreenshotPlugin/Controls/OAuthLoginForm.cs index ce721def5..e562b1185 100644 --- a/GreenshotPlugin/Controls/OAuthLoginForm.cs +++ b/GreenshotPlugin/Controls/OAuthLoginForm.cs @@ -60,7 +60,9 @@ namespace GreenshotPlugin.Controls { // The script errors are suppressed by using the ExtendedWebBrowser _browser.ScriptErrorsSuppressed = false; - _browser.DocumentCompleted += new WebBrowserDocumentCompletedEventHandler(Browser_DocumentCompleted); + _browser.DocumentCompleted += Browser_DocumentCompleted; + _browser.Navigated += Browser_Navigated; + _browser.Navigating += Browser_Navigating; _browser.Navigate(new Uri(authorizationLink)); } @@ -78,6 +80,16 @@ namespace GreenshotPlugin.Controls { CheckUrl(); } + private void Browser_Navigating(object sender, WebBrowserNavigatingEventArgs e) { + LOG.DebugFormat("Navigating to url: {0}", _browser.Url); + _addressTextBox.Text = e.Url.ToString(); + } + + private void Browser_Navigated(object sender, WebBrowserNavigatedEventArgs e) { + LOG.DebugFormat("Navigated to url: {0}", _browser.Url); + CheckUrl(); + } + private void CheckUrl() { if (_browser.Url.ToString().StartsWith(_callbackUrl)) { string queryParams = _browser.Url.Query; diff --git a/GreenshotPlugin/Core/NetworkHelper.cs b/GreenshotPlugin/Core/NetworkHelper.cs index 4ff398158..0acf63bf3 100644 --- a/GreenshotPlugin/Core/NetworkHelper.cs +++ b/GreenshotPlugin/Core/NetworkHelper.cs @@ -65,7 +65,6 @@ namespace GreenshotPlugin.Core { /// An Uri to specify the download location /// string with the file content public static string GetAsString(Uri uri) { - HttpWebRequest webRequest = CreateWebRequest(uri); return GetResponseAsString(CreateWebRequest(uri)); } @@ -391,15 +390,14 @@ namespace GreenshotPlugin.Core { /// Post the parameters "x-www-form-urlencoded" /// /// - /// - public static string UploadFormUrlEncoded(HttpWebRequest webRequest, IDictionary parameters) { + public static void UploadFormUrlEncoded(HttpWebRequest webRequest, IDictionary parameters) { webRequest.ContentType = "application/x-www-form-urlencoded"; string urlEncoded = NetworkHelper.GenerateQueryParameters(parameters); - using (var requestStream = webRequest.GetRequestStream()) - using (var streamWriter = new StreamWriter(requestStream, Encoding.UTF8)) { - streamWriter.Write(urlEncoded); + + byte[] data = Encoding.UTF8.GetBytes(urlEncoded); + using (var requestStream = webRequest.GetRequestStream()) { + requestStream.Write(data, 0, data.Length); } - return GetResponseAsString(webRequest); } /// @@ -423,6 +421,15 @@ namespace GreenshotPlugin.Core { /// The response data. /// TODO: This method should handle the StatusCode better! public static string GetResponseAsString(HttpWebRequest webRequest) { + return GetResponseAsString(webRequest, false); + } + + /// + /// + /// + /// + /// + public static string GetResponseAsString(HttpWebRequest webRequest, bool alsoReturnContentOnError) { string responseData = null; try { HttpWebResponse response = (HttpWebResponse) webRequest.GetResponse(); @@ -444,7 +451,13 @@ namespace GreenshotPlugin.Core { LOG.ErrorFormat("HTTP error {0}", response.StatusCode); using (Stream responseStream = response.GetResponseStream()) { if (responseStream != null) { - LOG.ErrorFormat("Content: {0}", new StreamReader(responseStream, true).ReadToEnd()); + using (StreamReader streamReader = new StreamReader(responseStream, true)) { + string errorContent = streamReader.ReadToEnd(); + if (alsoReturnContentOnError) { + return errorContent; + } + LOG.ErrorFormat("Content: {0}", errorContent); + } } } } diff --git a/GreenshotPlugin/Core/OAuthHelper.cs b/GreenshotPlugin/Core/OAuthHelper.cs index 2a8995131..94b73dc8c 100644 --- a/GreenshotPlugin/Core/OAuthHelper.cs +++ b/GreenshotPlugin/Core/OAuthHelper.cs @@ -134,20 +134,11 @@ namespace GreenshotPlugin.Core { /// /// The URL to get a Token /// - public string TokenUrlPattern { + public string TokenUrl { get; set; } - /// - /// Get formatted Token url (this will call a FormatWith(this) on the TokenUrlPattern - /// - public string FormattedTokenUrl { - get { - return TokenUrlPattern.FormatWith(this); - } - } - /// /// This is the redirect URL, in some implementations this is automatically set (LocalServerCodeReceiver) /// In some implementations this could be e.g. urn:ietf:wg:oauth:2.0:oob or urn:ietf:wg:oauth:2.0:oob:auto @@ -206,6 +197,15 @@ namespace GreenshotPlugin.Core { get; set; } + + /// + /// This contains the code returned from the authorization, but only shortly after it was received. + /// It will be cleared as soon as it was used. + /// + public string Code { + get; + set; + } } /// @@ -1046,7 +1046,7 @@ Greenshot received information from CloudServiceName. You can close this browser _returnValues.Add(name, nameValueCollection[name]); } } - } catch (Exception ex) { + } catch (Exception) { context.Response.OutputStream.Close(); throw; } @@ -1072,35 +1072,59 @@ Greenshot received information from CloudServiceName. You can close this browser /// Code to simplify OAuth 2 /// public static class OAuth2Helper { + private const string REFRESH_TOKEN = "refresh_token"; + private const string ACCESS_TOKEN = "access_token"; + private const string CODE = "code"; + private const string CLIENT_ID = "client_id"; + private const string CLIENT_SECRET = "client_secret"; + private const string GRANT_TYPE = "grant_type"; + private const string AUTHORIZATION_CODE = "authorization_code"; + private const string REDIRECT_URI = "redirect_uri"; + private const string EXPIRES_IN = "expires_in"; + /// /// Generate an OAuth 2 Token by using the supplied code /// /// Code to get the RefreshToken /// OAuth2Settings to update with the information that was retrieved - public static void GenerateRefreshToken(string code, OAuth2Settings settings) { - // Use the returned code to get a refresh code + public static void GenerateRefreshToken(OAuth2Settings settings) { IDictionary data = new Dictionary(); - data.Add("code", code); - data.Add("client_id", settings.ClientId); - data.Add("redirect_uri", settings.RedirectUrl); - data.Add("client_secret", settings.ClientSecret); - data.Add("grant_type", "authorization_code"); + // Use the returned code to get a refresh code + data.Add(CODE, settings.Code); + data.Add(CLIENT_ID, settings.ClientId); + data.Add(REDIRECT_URI, settings.RedirectUrl); + data.Add(CLIENT_SECRET, settings.ClientSecret); + data.Add(GRANT_TYPE, AUTHORIZATION_CODE); + foreach (string key in settings.AdditionalAttributes.Keys) { + data.Add(key, settings.AdditionalAttributes[key]); + } + + HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(settings.TokenUrl, HTTPMethod.POST); + NetworkHelper.UploadFormUrlEncoded(webRequest, data); + string accessTokenJsonResult = NetworkHelper.GetResponseAsString(webRequest, true); - HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(settings.FormattedTokenUrl, HTTPMethod.POST); - string accessTokenJsonResult = NetworkHelper.UploadFormUrlEncoded(webRequest, data); IDictionary refreshTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); + if (refreshTokenResult.ContainsKey("error")) { + if (refreshTokenResult.ContainsKey("error_description")) { + throw new Exception(string.Format("{0} - {1}", refreshTokenResult["error"], refreshTokenResult["error_description"])); + } else { + throw new Exception((string)refreshTokenResult["error"]); + } + } + // gives as described here: https://developers.google.com/identity/protocols/OAuth2InstalledApp // "access_token":"1/fFAGRNJru1FTz70BzhT3Zg", // "expires_in":3920, // "token_type":"Bearer", // "refresh_token":"1/xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI" - settings.AccessToken = (string)refreshTokenResult["access_token"] as string; - settings.RefreshToken = (string)refreshTokenResult["refresh_token"] as string; + settings.AccessToken = (string)refreshTokenResult[ACCESS_TOKEN] as string; + settings.RefreshToken = (string)refreshTokenResult[REFRESH_TOKEN] as string; - object seconds = refreshTokenResult["expires_in"]; + object seconds = refreshTokenResult[EXPIRES_IN]; if (seconds != null) { settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double)seconds); } + settings.Code = null; } /// @@ -1110,13 +1134,17 @@ Greenshot received information from CloudServiceName. You can close this browser /// public static void GenerateAccessToken(OAuth2Settings settings) { IDictionary data = new Dictionary(); - data.Add("refresh_token", settings.RefreshToken); - data.Add("client_id", settings.ClientId); - data.Add("client_secret", settings.ClientSecret); - data.Add("grant_type", "refresh_token"); + data.Add(REFRESH_TOKEN, settings.RefreshToken); + data.Add(CLIENT_ID, settings.ClientId); + data.Add(CLIENT_SECRET, settings.ClientSecret); + data.Add(GRANT_TYPE, REFRESH_TOKEN); + foreach (string key in settings.AdditionalAttributes.Keys) { + data.Add(key, settings.AdditionalAttributes[key]); + } - HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(settings.FormattedTokenUrl, HTTPMethod.POST); - string accessTokenJsonResult = NetworkHelper.UploadFormUrlEncoded(webRequest, data); + HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(settings.TokenUrl, HTTPMethod.POST); + NetworkHelper.UploadFormUrlEncoded(webRequest, data); + string accessTokenJsonResult = NetworkHelper.GetResponseAsString(webRequest, true); // gives as described here: https://developers.google.com/identity/protocols/OAuth2InstalledApp // "access_token":"1/fFAGRNJru1FTz70BzhT3Zg", @@ -1124,10 +1152,33 @@ Greenshot received information from CloudServiceName. You can close this browser // "token_type":"Bearer", IDictionary accessTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); - settings.AccessToken = (string)accessTokenResult["access_token"] as string; - object seconds = accessTokenResult["expires_in"]; + if (accessTokenResult.ContainsKey("error")) { + if ("invalid_grant" == (string)accessTokenResult["error"]) { + // Refresh token has also expired, we need a new one! + settings.RefreshToken = null; + settings.AccessToken = null; + settings.AccessTokenExpires = DateTimeOffset.MinValue; + settings.Code = null; + return; + } else { + if (accessTokenResult.ContainsKey("error_description")) { + throw new Exception(string.Format("{0} - {1}", accessTokenResult["error"], accessTokenResult["error_description"])); + } else { + throw new Exception((string)accessTokenResult["error"]); + } + } + } + + settings.AccessToken = (string)accessTokenResult[ACCESS_TOKEN] as string; + if (accessTokenResult.ContainsKey(REFRESH_TOKEN)) { + // Refresh the refresh token :) + settings.RefreshToken = (string)accessTokenResult[REFRESH_TOKEN] as string; + } + object seconds = accessTokenResult[EXPIRES_IN]; if (seconds != null) { settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double)seconds); + } else { + settings.AccessTokenExpires = DateTimeOffset.MaxValue; } } @@ -1168,8 +1219,9 @@ Greenshot received information from CloudServiceName. You can close this browser loginForm.ShowDialog(); if (loginForm.IsOk) { string code; - if (loginForm.CallbackParameters.TryGetValue("code", out code) && !string.IsNullOrEmpty(code)) { - GenerateRefreshToken(code, settings); + if (loginForm.CallbackParameters.TryGetValue(CODE, out code) && !string.IsNullOrEmpty(code)) { + settings.Code = code; + GenerateRefreshToken(settings); return true; } } @@ -1187,8 +1239,9 @@ Greenshot received information from CloudServiceName. You can close this browser IDictionary result = codeReceiver.ReceiveCode(settings); string code; - if (result.TryGetValue("code", out code) && !string.IsNullOrEmpty(code)) { - GenerateRefreshToken(code, settings); + if (result.TryGetValue(CODE, out code) && !string.IsNullOrEmpty(code)) { + settings.Code = code; + GenerateRefreshToken(settings); return true; } string error; @@ -1224,17 +1277,25 @@ Greenshot received information from CloudServiceName. You can close this browser public static void CheckAndAuthenticateOrRefresh(OAuth2Settings settings) { // Get Refresh / Access token if (string.IsNullOrEmpty(settings.RefreshToken)) { - if (!OAuth2Helper.Authenticate(settings)) { + if (!Authenticate(settings)) { throw new Exception("Authentication cancelled"); } } - if (settings.IsAccessTokenExpired) { - OAuth2Helper.GenerateAccessToken(settings); + GenerateAccessToken(settings); + // Get Refresh / Access token + if (string.IsNullOrEmpty(settings.RefreshToken)) { + if (!Authenticate(settings)) { + throw new Exception("Authentication cancelled"); + } + GenerateAccessToken(settings); + } + } + if (settings.IsAccessTokenExpired) { + throw new Exception("Authentication failed"); } } - /// /// CreateWebRequest ready for OAuth 2 access /// @@ -1245,8 +1306,8 @@ Greenshot received information from CloudServiceName. You can close this browser public static HttpWebRequest CreateOAuth2WebRequest(HTTPMethod method, string url, OAuth2Settings settings) { CheckAndAuthenticateOrRefresh(settings); - HttpWebRequest webRequest = (HttpWebRequest)NetworkHelper.CreateWebRequest(url, method); - OAuth2Helper.AddOAuth2Credentials(webRequest, settings); + HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(url, method); + AddOAuth2Credentials(webRequest, settings); return webRequest; } }