diff --git a/GreenshotBoxPlugin/BoxUtils.cs b/GreenshotBoxPlugin/BoxUtils.cs index 0c5b2b10f..fb14aae29 100644 --- a/GreenshotBoxPlugin/BoxUtils.cs +++ b/GreenshotBoxPlugin/BoxUtils.cs @@ -25,6 +25,7 @@ using System.Drawing; using System.IO; using System.Runtime.Serialization.Json; using System.Text; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; namespace GreenshotBoxPlugin { diff --git a/GreenshotDropboxPlugin/DropboxUtils.cs b/GreenshotDropboxPlugin/DropboxUtils.cs index 434cdcdcf..51f1dfc6f 100644 --- a/GreenshotDropboxPlugin/DropboxUtils.cs +++ b/GreenshotDropboxPlugin/DropboxUtils.cs @@ -22,6 +22,7 @@ using System; using System.Collections.Generic; using System.Drawing; using GreenshotPlugin.Core; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; using GreenshotPlugin.Interfaces.Plugin; diff --git a/GreenshotFlickrPlugin/FlickrUtils.cs b/GreenshotFlickrPlugin/FlickrUtils.cs index 332a9c6e6..c5d5ade1e 100644 --- a/GreenshotFlickrPlugin/FlickrUtils.cs +++ b/GreenshotFlickrPlugin/FlickrUtils.cs @@ -24,6 +24,7 @@ using System.Collections.Generic; using System.Drawing; using System.Xml; using GreenshotPlugin.Core; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; using GreenshotPlugin.Interfaces.Plugin; diff --git a/GreenshotImgurPlugin/ImgurUtils.cs b/GreenshotImgurPlugin/ImgurUtils.cs index a38c12b95..9b1e73e68 100644 --- a/GreenshotImgurPlugin/ImgurUtils.cs +++ b/GreenshotImgurPlugin/ImgurUtils.cs @@ -20,11 +20,11 @@ */ using System; using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Net; using GreenshotPlugin.Core; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; using GreenshotPlugin.Interfaces.Plugin; @@ -37,10 +37,8 @@ namespace GreenshotImgurPlugin { private static readonly log4net.ILog Log = log4net.LogManager.GetLogger(typeof(ImgurUtils)); private const string SmallUrlPattern = "http://i.imgur.com/{0}s.jpg"; private static readonly ImgurConfiguration Config = IniConfig.GetIniSection(); - private const string AuthUrlPattern = "https://api.imgur.com/oauth2/authorize?response_type=token&client_id={ClientId}&state={State}"; - private const string TokenUrl = "https://api.imgur.com/oauth2/token"; - /// + /// /// Check if we need to load the history /// /// @@ -162,20 +160,20 @@ namespace GreenshotImgurPlugin { responseString = reader.ReadToEnd(); } } catch (Exception ex) { - Log.Error("Upload to imgur gave an exeption: ", ex); + Log.Error("Upload to imgur gave an exception: ", ex); throw; } } else { var oauth2Settings = new OAuth2Settings { - AuthUrlPattern = AuthUrlPattern, - TokenUrl = TokenUrl, + AuthUrlPattern = "https://api.imgur.com/oauth2/authorize?response_type=token&client_id={ClientId}&state={State}", + TokenUrl = "https://api.imgur.com/oauth2/token", RedirectUrl = "https://getgreenshot.org/oauth/imgur", CloudServiceName = "Imgur", ClientId = ImgurCredentials.CONSUMER_KEY, ClientSecret = ImgurCredentials.CONSUMER_SECRET, - AuthorizeMode = OAuth2AuthorizeMode.OutOfBoundAuto, + AuthorizeMode = OAuth2AuthorizeMode.MonitorTitle, RefreshToken = Config.RefreshToken, AccessToken = Config.AccessToken, AccessTokenExpires = Config.AccessTokenExpires @@ -221,7 +219,7 @@ namespace GreenshotImgurPlugin { Log.InfoFormat("Retrieving Imgur image for {0} with url {1}", imgurInfo.Hash, imgurInfo.SmallSquare); HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(string.Format(SmallUrlPattern, imgurInfo.Hash), HTTPMethod.GET); webRequest.ServicePoint.Expect100Continue = false; - // Not for getting the thumbnail, in anonymous modus + // Not for getting the thumbnail, in anonymous mode //SetClientId(webRequest); using WebResponse response = webRequest.GetResponse(); LogRateLimitInfo(response); @@ -304,7 +302,7 @@ namespace GreenshotImgurPlugin { } } } - // Make sure we remove it from the history, if no error occured + // Make sure we remove it from the history, if no error occurred Config.runtimeImgurHistory.Remove(imgurInfo.Hash); Config.ImgurUploadHistory.Remove(imgurInfo.Hash); imgurInfo.Image = null; diff --git a/GreenshotPhotobucketPlugin/PhotobucketUtils.cs b/GreenshotPhotobucketPlugin/PhotobucketUtils.cs index b681edac7..677084e92 100644 --- a/GreenshotPhotobucketPlugin/PhotobucketUtils.cs +++ b/GreenshotPhotobucketPlugin/PhotobucketUtils.cs @@ -24,6 +24,7 @@ using System.Collections.Generic; using System.Drawing; using System.Xml; using GreenshotPlugin.Core; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; using GreenshotPlugin.Interfaces.Plugin; diff --git a/GreenshotPicasaPlugin/PicasaUtils.cs b/GreenshotPicasaPlugin/PicasaUtils.cs index eb2b2be2e..637c8aaa3 100644 --- a/GreenshotPicasaPlugin/PicasaUtils.cs +++ b/GreenshotPicasaPlugin/PicasaUtils.cs @@ -21,6 +21,7 @@ using GreenshotPlugin.Core; using System; using System.Xml; +using GreenshotPlugin.Core.OAuth; using GreenshotPlugin.IniFile; using GreenshotPlugin.Interfaces; using GreenshotPlugin.Interfaces.Plugin; diff --git a/GreenshotPlugin/Controls/OAuthLoginForm.cs b/GreenshotPlugin/Controls/OAuthLoginForm.cs index 2b834d128..a44940be1 100644 --- a/GreenshotPlugin/Controls/OAuthLoginForm.cs +++ b/GreenshotPlugin/Controls/OAuthLoginForm.cs @@ -1,20 +1,20 @@ /* * Greenshot - a free and open source screenshot tool * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom - * + * * For more information see: http://getgreenshot.org/ * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 1 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ @@ -32,11 +32,10 @@ namespace GreenshotPlugin.Controls { public sealed partial class OAuthLoginForm : Form { private static readonly ILog LOG = LogManager.GetLogger(typeof(OAuthLoginForm)); private readonly string _callbackUrl; - private IDictionary _callbackParameters; - - public IDictionary CallbackParameters => _callbackParameters; - public bool IsOk => DialogResult == DialogResult.OK; + public IDictionary CallbackParameters { get; private set; } + + public bool IsOk => DialogResult == DialogResult.OK; public OAuthLoginForm(string browserTitle, Size size, string authorizationLink, string callbackUrl) { // Make sure Greenshot uses the correct browser version @@ -94,7 +93,7 @@ namespace GreenshotPlugin.Controls { if (queryParams.Length > 0) { queryParams = NetworkHelper.UrlDecode(queryParams); //Store the Token and Token Secret - _callbackParameters = NetworkHelper.ParseQueryString(queryParams); + CallbackParameters = NetworkHelper.ParseQueryString(queryParams); } DialogResult = DialogResult.OK; } @@ -102,7 +101,7 @@ namespace GreenshotPlugin.Controls { private void AddressTextBox_KeyPress(object sender, KeyPressEventArgs e) { //Cancel the key press so the user can't enter a new url - e.Handled = true; + e.Handled = true; } } } diff --git a/GreenshotPlugin/Core/OAuth/LocalServerCodeReceiver.cs b/GreenshotPlugin/Core/OAuth/LocalServerCodeReceiver.cs new file mode 100644 index 000000000..a4915e8b6 --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/LocalServerCodeReceiver.cs @@ -0,0 +1,183 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using log4net; + +namespace GreenshotPlugin.Core.OAuth +{ + /// + /// OAuth 2.0 verification code receiver that runs a local server on a free port + /// and waits for a call with the authorization verification code. + /// + public class LocalServerCodeReceiver { + private static readonly ILog Log = LogManager.GetLogger(typeof(LocalServerCodeReceiver)); + private readonly ManualResetEvent _ready = new ManualResetEvent(true); + + /// + /// The call back format. Expects one port parameter. + /// Default: http://localhost:{0}/authorize/ + /// + public string LoopbackCallbackUrl { get; set; } = "http://localhost:{0}/authorize/"; + + /// + /// HTML code to to return the _browser, default it will try to close the _browser / tab, this won't always work. + /// You can use CloudServiceName where you want to show the CloudServiceName from your OAuth2 settings + /// + public string ClosePageResponse { get; set; } = @" +OAuth 2.0 Authentication CloudServiceName + +Greenshot received information from CloudServiceName. You can close this browser / tab if it is not closed itself... + + +"; + + private string _redirectUri; + /// + /// The URL to redirect to + /// + protected string RedirectUri { + get { + if (!string.IsNullOrEmpty(_redirectUri)) { + return _redirectUri; + } + + return _redirectUri = string.Format(LoopbackCallbackUrl, GetRandomUnusedPort()); + } + } + + private string _cloudServiceName; + + private readonly IDictionary _returnValues = new Dictionary(); + + + /// + /// The OAuth code receiver + /// + /// + /// Dictionary with values + public IDictionary ReceiveCode(OAuth2Settings oauth2Settings) { + // Set the redirect URL on the settings + oauth2Settings.RedirectUrl = RedirectUri; + _cloudServiceName = oauth2Settings.CloudServiceName; + using (var listener = new HttpListener()) { + listener.Prefixes.Add(oauth2Settings.RedirectUrl); + try { + listener.Start(); + + // Get the formatted FormattedAuthUrl + string authorizationUrl = oauth2Settings.FormattedAuthUrl; + Log.DebugFormat("Open a browser with: {0}", authorizationUrl); + Process.Start(authorizationUrl); + + // Wait to get the authorization code response. + var context = listener.BeginGetContext(ListenerCallback, listener); + _ready.Reset(); + + while (!context.AsyncWaitHandle.WaitOne(1000, true)) { + Log.Debug("Waiting for response"); + } + } catch (Exception) { + // Make sure we can clean up, also if the thead is aborted + _ready.Set(); + throw; + } finally { + _ready.WaitOne(); + listener.Close(); + } + } + return _returnValues; + } + + /// + /// Handle a connection async, this allows us to break the waiting + /// + /// IAsyncResult + private void ListenerCallback(IAsyncResult result) { + HttpListener listener = (HttpListener)result.AsyncState; + + //If not listening return immediately as this method is called one last time after Close() + if (!listener.IsListening) { + return; + } + + // Use EndGetContext to complete the asynchronous operation. + HttpListenerContext context = listener.EndGetContext(result); + + + // Handle request + HttpListenerRequest request = context.Request; + try { + NameValueCollection nameValueCollection = request.QueryString; + + // Get response object. + using (HttpListenerResponse response = context.Response) { + // Write a "close" response. + byte[] buffer = Encoding.UTF8.GetBytes(ClosePageResponse.Replace("CloudServiceName", _cloudServiceName)); + // Write to response stream. + response.ContentLength64 = buffer.Length; + using var stream = response.OutputStream; + stream.Write(buffer, 0, buffer.Length); + } + + // Create a new response URL with a dictionary that contains all the response query parameters. + foreach (var name in nameValueCollection.AllKeys) { + if (!_returnValues.ContainsKey(name)) { + _returnValues.Add(name, nameValueCollection[name]); + } + } + } catch (Exception) { + context.Response.OutputStream.Close(); + throw; + } + _ready.Set(); + } + + /// + /// Returns a random, unused port. + /// + /// port to use + private static int GetRandomUnusedPort() { + var listener = new TcpListener(IPAddress.Loopback, 0); + try { + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } finally { + listener.Stop(); + } + } + } +} \ No newline at end of file diff --git a/GreenshotPlugin/Core/OAuth/OAuth2AuthorizeMode.cs b/GreenshotPlugin/Core/OAuth/OAuth2AuthorizeMode.cs new file mode 100644 index 000000000..b5eee19b3 --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/OAuth2AuthorizeMode.cs @@ -0,0 +1,34 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace GreenshotPlugin.Core.OAuth +{ + /// + /// Specify the authorize mode that is used to get the token from the cloud service. + /// + public enum OAuth2AuthorizeMode { + Unknown, // Will give an exception, caller needs to specify another value + LocalServer, // Will specify a redirect URL to http://localhost:port/authorize, while having a HttpListener + MonitorTitle, // Will monitor for title changes, the title needs the status and query params + Pin, // Not implemented yet: Will ask the user to enter the shown PIN + EmbeddedBrowser // Will open into an embedded _browser (OAuthLoginForm), and catch the redirect + } +} \ No newline at end of file diff --git a/GreenshotPlugin/Core/OAuth/OAuth2Helper.cs b/GreenshotPlugin/Core/OAuth/OAuth2Helper.cs new file mode 100644 index 000000000..629a764f8 --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/OAuth2Helper.cs @@ -0,0 +1,384 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Net; +using System.Windows.Forms; +using GreenshotPlugin.Controls; +using GreenshotPlugin.Hooking; + +namespace GreenshotPlugin.Core.OAuth { + /// + /// Code to simplify OAuth 2 + /// + public static class OAuth2Helper { + private const string RefreshToken = "refresh_token"; + private const string AccessToken = "access_token"; + private const string Code = "code"; + private const string Error = "error"; + private const string ClientId = "client_id"; + private const string ClientSecret = "client_secret"; + private const string GrantType = "grant_type"; + private const string AuthorizationCode = "authorization_code"; + private const string RedirectUri = "redirect_uri"; + private const string ExpiresIn = "expires_in"; + + /// + /// Generate an OAuth 2 Token by using the supplied code + /// + /// OAuth2Settings to update with the information that was retrieved + public static void GenerateRefreshToken(OAuth2Settings settings) { + IDictionary data = new Dictionary + { + // Use the returned code to get a refresh code + { Code, settings.Code }, + { ClientId, settings.ClientId }, + { RedirectUri, settings.RedirectUrl }, + { ClientSecret, settings.ClientSecret }, + { GrantType, AuthorizationCode } + }; + 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); + + IDictionary refreshTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); + if (refreshTokenResult.ContainsKey("error")) + { + if (refreshTokenResult.ContainsKey("error_description")) { + throw new Exception($"{refreshTokenResult["error"]} - {refreshTokenResult["error_description"]}"); + } + 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" + if (refreshTokenResult.ContainsKey(AccessToken)) + { + settings.AccessToken = (string)refreshTokenResult[AccessToken]; + } + if (refreshTokenResult.ContainsKey(RefreshToken)) + { + settings.RefreshToken = (string)refreshTokenResult[RefreshToken]; + } + if (refreshTokenResult.ContainsKey(ExpiresIn)) + { + object seconds = refreshTokenResult[ExpiresIn]; + if (seconds != null) + { + settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double)seconds); + } + } + settings.Code = null; + } + + /// + /// Used to update the settings with the callback information + /// + /// OAuth2Settings + /// IDictionary + /// true if the access token is already in the callback + private static bool UpdateFromCallback(OAuth2Settings settings, IDictionary callbackParameters) + { + if (!callbackParameters.ContainsKey(AccessToken)) + { + return false; + } + if (callbackParameters.ContainsKey(RefreshToken)) + { + // Refresh the refresh token :) + settings.RefreshToken = callbackParameters[RefreshToken]; + } + if (callbackParameters.ContainsKey(ExpiresIn)) + { + var expiresIn = callbackParameters[ExpiresIn]; + settings.AccessTokenExpires = DateTimeOffset.MaxValue; + if (expiresIn != null) + { + if (double.TryParse(expiresIn, out var seconds)) + { + settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds(seconds); + } + } + } + settings.AccessToken = callbackParameters[AccessToken]; + return true; + } + + /// + /// Go out and retrieve a new access token via refresh-token with the TokenUrl in the settings + /// Will upate the access token, refresh token, expire date + /// + /// + public static void GenerateAccessToken(OAuth2Settings settings) { + IDictionary data = new Dictionary + { + { RefreshToken, settings.RefreshToken }, + { ClientId, settings.ClientId }, + { ClientSecret, settings.ClientSecret }, + { GrantType, RefreshToken } + }; + 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); + + // gives as described here: https://developers.google.com/identity/protocols/OAuth2InstalledApp + // "access_token":"1/fFAGRNJru1FTz70BzhT3Zg", + // "expires_in":3920, + // "token_type":"Bearer", + + IDictionary accessTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); + 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($"{accessTokenResult["error"]} - {accessTokenResult["error_description"]}"); + } else { + throw new Exception((string)accessTokenResult["error"]); + } + } + } + + if (accessTokenResult.ContainsKey(AccessToken)) + { + settings.AccessToken = (string) accessTokenResult[AccessToken]; + settings.AccessTokenExpires = DateTimeOffset.MaxValue; + } + if (accessTokenResult.ContainsKey(RefreshToken)) { + // Refresh the refresh token :) + settings.RefreshToken = (string)accessTokenResult[RefreshToken]; + } + if (accessTokenResult.ContainsKey(ExpiresIn)) + { + object seconds = accessTokenResult[ExpiresIn]; + if (seconds != null) + { + settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double) seconds); + } + } + } + + /// + /// Authenticate by using the mode specified in the settings + /// + /// OAuth2Settings + /// false if it was canceled, true if it worked, exception if not + public static bool Authenticate(OAuth2Settings settings) { + var completed = settings.AuthorizeMode switch + { + OAuth2AuthorizeMode.LocalServer => AuthenticateViaLocalServer(settings), + OAuth2AuthorizeMode.EmbeddedBrowser => AuthenticateViaEmbeddedBrowser(settings), + OAuth2AuthorizeMode.MonitorTitle => AuthenticateViaDefaultBrowser(settings), + _ => throw new NotImplementedException($"Authorize mode '{settings.AuthorizeMode}' is not 'yet' implemented."), + }; + return completed; + } + + /// + /// Authenticate via the default browser, using the browser title. + /// If this works, return the code + /// + /// OAuth2Settings with the Auth / Token url etc + /// true if completed, false if canceled + private static bool AuthenticateViaDefaultBrowser(OAuth2Settings settings) + { + var monitor = new WindowsTitleMonitor(); + + string error = null; + var fields = new HashSet(); + int nrOfFields = 100; + + monitor.TitleChangeEvent += args => + { + if (!args.Title.Contains(settings.State)) + { + return; + } + + var title = args.Title; + title = title.Substring(0,title.IndexOf(' ')); + + var parameters = NetworkHelper.ParseQueryString(title); + + if (parameters.ContainsKey("nr")) + { + nrOfFields = int.Parse(parameters["nr"]); + } + + foreach (var key in parameters.Keys) + { + fields.Add(key); + switch (key) + { + case AccessToken: + settings.AccessToken = parameters[key]; + break; + case ExpiresIn: + if (int.TryParse(parameters[key], out var seconds)) + { + settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds(seconds); + } + break; + case RefreshToken: + settings.RefreshToken = parameters[key]; + break; + case Error: + error = parameters[key]; + break; + } + } + }; + using (var process = Process.Start(settings.FormattedAuthUrl)) + { + while (nrOfFields > fields.Count) + { + // Have the thread process Forms events + Application.DoEvents(); + } + }; + monitor.Dispose(); + return error == null; + } + + /// + /// Authenticate via an embedded browser + /// If this works, return the code + /// + /// OAuth2Settings with the Auth / Token url etc + /// true if completed, false if canceled + private static bool AuthenticateViaEmbeddedBrowser(OAuth2Settings settings) { + if (string.IsNullOrEmpty(settings.CloudServiceName)) { + throw new ArgumentNullException(nameof(settings.CloudServiceName)); + } + if (settings.BrowserSize == Size.Empty) { + throw new ArgumentNullException(nameof(settings.BrowserSize)); + } + OAuthLoginForm loginForm = new OAuthLoginForm($"Authorize {settings.CloudServiceName}", settings.BrowserSize, settings.FormattedAuthUrl, settings.RedirectUrl); + loginForm.ShowDialog(); + if (!loginForm.IsOk) return false; + if (loginForm.CallbackParameters.TryGetValue(Code, out var code) && !string.IsNullOrEmpty(code)) { + settings.Code = code; + GenerateRefreshToken(settings); + return true; + } + return UpdateFromCallback(settings, loginForm.CallbackParameters); + } + + /// + /// Authenticate via a local server by using the LocalServerCodeReceiver + /// If this works, return the code + /// + /// OAuth2Settings with the Auth / Token url etc + /// true if completed + private static bool AuthenticateViaLocalServer(OAuth2Settings settings) { + var codeReceiver = new LocalServerCodeReceiver(); + IDictionary result = codeReceiver.ReceiveCode(settings); + + if (result.TryGetValue(Code, out var code) && !string.IsNullOrEmpty(code)) { + settings.Code = code; + GenerateRefreshToken(settings); + return true; + } + + if (result.TryGetValue("error", out var error)) { + if (result.TryGetValue("error_description", out var errorDescription)) { + throw new Exception(errorDescription); + } + if ("access_denied" == error) { + throw new UnauthorizedAccessException("Access denied"); + } + throw new Exception(error); + } + return false; + } + + /// + /// Simple helper to add the Authorization Bearer header + /// + /// WebRequest + /// OAuth2Settings + public static void AddOAuth2Credentials(HttpWebRequest webRequest, OAuth2Settings settings) { + if (!string.IsNullOrEmpty(settings.AccessToken)) { + webRequest.Headers.Add("Authorization", "Bearer " + settings.AccessToken); + } + } + + /// + /// Check and authenticate or refresh tokens + /// + /// OAuth2Settings + public static void CheckAndAuthenticateOrRefresh(OAuth2Settings settings) { + // Get Refresh / Access token + if (string.IsNullOrEmpty(settings.RefreshToken)) { + if (!Authenticate(settings)) { + throw new Exception("Authentication cancelled"); + } + } + if (settings.IsAccessTokenExpired) { + 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 + /// + /// HTTPMethod + /// + /// OAuth2Settings + /// HttpWebRequest + public static HttpWebRequest CreateOAuth2WebRequest(HTTPMethod method, string url, OAuth2Settings settings) { + CheckAndAuthenticateOrRefresh(settings); + + HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(url, method); + AddOAuth2Credentials(webRequest, settings); + return webRequest; + } + } +} diff --git a/GreenshotPlugin/Core/OAuth/OAuth2Settings.cs b/GreenshotPlugin/Core/OAuth/OAuth2Settings.cs new file mode 100644 index 000000000..1b8bd1042 --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/OAuth2Settings.cs @@ -0,0 +1,177 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; +using System.Collections.Generic; +using System.Drawing; + +namespace GreenshotPlugin.Core.OAuth +{ + /// + /// Settings for the OAuth 2 protocol + /// + public class OAuth2Settings { + public OAuth2Settings() { + AdditionalAttributes = new Dictionary(); + // Create a default state + var state = Guid.NewGuid().ToString(); + // Only store a small part of the GUID + State = state.Substring(0, state.IndexOf('-')-1); + AuthorizeMode = OAuth2AuthorizeMode.Unknown; + } + + public OAuth2AuthorizeMode AuthorizeMode { + get; + set; + } + + /// + /// Specify the name of the cloud service, so it can be used in window titles, logs etc + /// + public string CloudServiceName { + get; + set; + } + + /// + /// Specify the size of the embedded Browser, if using this + /// + public Size BrowserSize { + get; + set; + } + + /// + /// The OAuth 2 client id + /// + public string ClientId { + get; + set; + } + + /// + /// The OAuth 2 client secret + /// + public string ClientSecret { + get; + set; + } + + /// + /// The OAuth 2 state, this is something that is passed to the server, is not processed but returned back to the client. + /// e.g. a correlation ID + /// Default this is filled with a new Guid + /// + public string State { + get; + set; + } + + /// + /// The authorization URL where the values of this class can be "injected" + /// + public string AuthUrlPattern { + get; + set; + } + + /// + /// Get formatted Auth url (this will call a FormatWith(this) on the AuthUrlPattern + /// + public string FormattedAuthUrl => AuthUrlPattern.FormatWith(this); + + /// + /// The URL to get a Token + /// + public string TokenUrl { + get; + set; + } + + /// + /// 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 + /// + public string RedirectUrl { + get; + set; + } + + /// + /// Bearer token for accessing OAuth 2 services + /// + public string AccessToken { + get; + set; + } + + /// + /// Expire time for the AccessToken, this this time (-60 seconds) is passed a new AccessToken needs to be generated with the RefreshToken + /// + public DateTimeOffset AccessTokenExpires { + get; + set; + } + + /// + /// Return true if the access token is expired. + /// Important "side-effect": if true is returned the AccessToken will be set to null! + /// + public bool IsAccessTokenExpired { + get { + bool expired = true; + if (!string.IsNullOrEmpty(AccessToken)) { + expired = DateTimeOffset.Now.AddSeconds(60) > AccessTokenExpires; + } + // Make sure the token is not usable + if (expired) { + AccessToken = null; + } + return expired; + } + } + + /// + /// Token used to get a new Access Token + /// + public string RefreshToken { + get; + set; + } + + /// + /// Put anything in here which is needed for the OAuth 2 implementation of this specific service but isn't generic, e.g. for Google there is a "scope" + /// + public IDictionary AdditionalAttributes { + 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; + } + } +} \ No newline at end of file diff --git a/GreenshotPlugin/Core/OAuth/OAuthSession.cs b/GreenshotPlugin/Core/OAuth/OAuthSession.cs new file mode 100644 index 000000000..9a40c0b8d --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/OAuthSession.cs @@ -0,0 +1,629 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using GreenshotPlugin.Controls; +using log4net; + +namespace GreenshotPlugin.Core.OAuth +{ + /// + /// An OAuth 1 session object + /// + public class OAuthSession { + private static readonly ILog Log = LogManager.GetLogger(typeof(OAuthSession)); + protected const string OAUTH_VERSION = "1.0"; + protected const string OAUTH_PARAMETER_PREFIX = "oauth_"; + + // + // List of know and used oauth parameters' names + // + protected const string OAUTH_CONSUMER_KEY_KEY = "oauth_consumer_key"; + protected const string OAUTH_CALLBACK_KEY = "oauth_callback"; + protected const string OAUTH_VERSION_KEY = "oauth_version"; + protected const string OAUTH_SIGNATURE_METHOD_KEY = "oauth_signature_method"; + protected const string OAUTH_TIMESTAMP_KEY = "oauth_timestamp"; + protected const string OAUTH_NONCE_KEY = "oauth_nonce"; + protected const string OAUTH_TOKEN_KEY = "oauth_token"; + protected const string OAUTH_VERIFIER_KEY = "oauth_verifier"; + protected const string OAUTH_TOKEN_SECRET_KEY = "oauth_token_secret"; + protected const string OAUTH_SIGNATURE_KEY = "oauth_signature"; + + protected const string HMACSHA1SignatureType = "HMAC-SHA1"; + protected const string PlainTextSignatureType = "PLAINTEXT"; + + protected static Random random = new Random(); + + protected const string UnreservedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~"; + + private string _userAgent = "Greenshot"; + private IDictionary _requestTokenResponseParameters; + + public IDictionary RequestTokenParameters { get; } = new Dictionary(); + + /// + /// Parameters of the last called getAccessToken + /// + public IDictionary AccessTokenResponseParameters { get; private set; } + + /// + /// Parameters of the last called getRequestToken + /// + public IDictionary RequestTokenResponseParameters => _requestTokenResponseParameters; + + private readonly string _consumerKey; + private readonly string _consumerSecret; + + // default _browser size + + public HTTPMethod RequestTokenMethod { + get; + set; + } + public HTTPMethod AccessTokenMethod { + get; + set; + } + public string RequestTokenUrl { + get; + set; + } + public string AuthorizeUrl { + get; + set; + } + public string AccessTokenUrl { + get; + set; + } + public string Token { + get; + set; + } + public string TokenSecret { + get; + set; + } + public string Verifier { + get; + set; + } + public OAuthSignatureTypes SignatureType { + get; + set; + } + + public bool UseMultipartFormData { get; set; } + public string UserAgent { + get { + return _userAgent; + } + set { + _userAgent = value; + } + } + public string CallbackUrl { get; set; } = "http://getgreenshot.org"; + + public bool CheckVerifier { get; set; } = true; + + public Size BrowserSize { get; set; } = new Size(864, 587); + + public string LoginTitle { get; set; } = "Authorize Greenshot access"; + + public bool UseHttpHeadersForAuthorization { get; set; } = true; + + public bool AutoLogin { + get; + set; + } + + /// + /// Create an OAuthSession with the consumerKey / consumerSecret + /// + /// "Public" key for the encoding. When using RSASHA1 this is the path to the private key file + /// "Private" key for the encoding. when usin RSASHA1 this is the password for the private key file + public OAuthSession(string consumerKey, string consumerSecret) { + _consumerKey = consumerKey; + _consumerSecret = consumerSecret; + UseMultipartFormData = true; + RequestTokenMethod = HTTPMethod.GET; + AccessTokenMethod = HTTPMethod.GET; + SignatureType = OAuthSignatureTypes.HMACSHA1; + AutoLogin = true; + } + + /// + /// Helper function to compute a hash value + /// + /// The hashing algorithm used. If that algorithm needs some initialization, like HMAC and its derivatives, they should be initialized prior to passing it to this function + /// The data to hash + /// a Base64 string of the hash value + private static string ComputeHash(HashAlgorithm hashAlgorithm, string data) { + if (hashAlgorithm == null) { + throw new ArgumentNullException(nameof(hashAlgorithm)); + } + + if (string.IsNullOrEmpty(data)) { + throw new ArgumentNullException(nameof(data)); + } + + byte[] dataBuffer = Encoding.UTF8.GetBytes(data); + byte[] hashBytes = hashAlgorithm.ComputeHash(dataBuffer); + + return Convert.ToBase64String(hashBytes); + } + + /// + /// Generate the normalized paramter string + /// + /// the list of query parameters + /// a string with the normalized query parameters + private static string GenerateNormalizedParametersString(IDictionary queryParameters) { + if (queryParameters == null || queryParameters.Count == 0) { + return string.Empty; + } + + queryParameters = new SortedDictionary(queryParameters); + + StringBuilder sb = new StringBuilder(); + foreach (string key in queryParameters.Keys) { + if (queryParameters[key] is string) { + sb.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", key, UrlEncode3986($"{queryParameters[key]}")); + } + } + sb.Remove(sb.Length - 1, 1); + + return sb.ToString(); + } + + /// + /// This is a different Url Encode implementation since the default .NET one outputs the percent encoding in lower case. + /// While this is not a problem with the percent encoding spec, it is used in upper case throughout OAuth + /// The resulting string is for UTF-8 encoding! + /// + /// The value to Url encode + /// Returns a Url encoded string (unicode) with UTF-8 encoded % values + public static string UrlEncode3986(string value) { + StringBuilder result = new StringBuilder(); + + foreach (char symbol in value) { + if (UnreservedChars.IndexOf(symbol) != -1) { + result.Append(symbol); + } else { + byte[] utf8Bytes = Encoding.UTF8.GetBytes(symbol.ToString()); + foreach(byte utf8Byte in utf8Bytes) { + result.AppendFormat("%{0:X2}", utf8Byte); + } + } + } + + return result.ToString(); + } + + /// + /// Generate the timestamp for the signature + /// + /// + public static string GenerateTimeStamp() { + // Default implementation of UNIX time of the current UTC time + TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0); + return Convert.ToInt64(ts.TotalSeconds).ToString(); + } + + /// + /// Generate a nonce + /// + /// + public static string GenerateNonce() { + // Just a simple implementation of a random number between 123400 and 9999999 + return random.Next(123400, 9999999).ToString(); + } + + /// + /// Get the request token using the consumer key and secret. Also initializes tokensecret + /// + /// response, this doesn't need to be used!! + private string GetRequestToken() { + IDictionary parameters = new Dictionary(); + foreach(var value in RequestTokenParameters) { + parameters.Add(value); + } + Sign(RequestTokenMethod, RequestTokenUrl, parameters); + string response = MakeRequest(RequestTokenMethod, RequestTokenUrl, null, parameters, null); + if (!string.IsNullOrEmpty(response)) { + response = NetworkHelper.UrlDecode(response); + Log.DebugFormat("Request token response: {0}", response); + _requestTokenResponseParameters = NetworkHelper.ParseQueryString(response); + if (_requestTokenResponseParameters.TryGetValue(OAUTH_TOKEN_KEY, out var value)) { + Token = value; + TokenSecret = _requestTokenResponseParameters[OAUTH_TOKEN_SECRET_KEY]; + } + } + return response; + } + + /// + /// Authorize the token by showing the dialog + /// + /// Pass the response from the server's request token, so if there is something wrong we can show it. + /// The request token. + private string GetAuthorizeToken(string requestTokenResponse) { + if (string.IsNullOrEmpty(Token)) { + Exception e = new Exception("The request token is not set, service responded with: " + requestTokenResponse); + throw e; + } + Log.DebugFormat("Opening AuthorizationLink: {0}", AuthorizationLink); + OAuthLoginForm oAuthLoginForm = new OAuthLoginForm(LoginTitle, BrowserSize, AuthorizationLink, CallbackUrl); + oAuthLoginForm.ShowDialog(); + if (oAuthLoginForm.IsOk) { + if (oAuthLoginForm.CallbackParameters != null) { + if (oAuthLoginForm.CallbackParameters.TryGetValue(OAUTH_TOKEN_KEY, out var tokenValue)) { + Token = tokenValue; + } + + if (oAuthLoginForm.CallbackParameters.TryGetValue(OAUTH_VERIFIER_KEY, out var verifierValue)) { + Verifier = verifierValue; + } + } + } + if (CheckVerifier) { + if (!string.IsNullOrEmpty(Verifier)) { + return Token; + } + return null; + } + return Token; + } + + /// + /// Get the access token + /// + /// The access token. + private string GetAccessToken() { + if (string.IsNullOrEmpty(Token) || (CheckVerifier && string.IsNullOrEmpty(Verifier))) { + Exception e = new Exception("The request token and verifier were not set"); + throw e; + } + + IDictionary parameters = new Dictionary(); + Sign(AccessTokenMethod, AccessTokenUrl, parameters); + string response = MakeRequest(AccessTokenMethod, AccessTokenUrl, null, parameters, null); + if (!string.IsNullOrEmpty(response)) { + response = NetworkHelper.UrlDecode(response); + Log.DebugFormat("Access token response: {0}", response); + AccessTokenResponseParameters = NetworkHelper.ParseQueryString(response); + if (AccessTokenResponseParameters.TryGetValue(OAUTH_TOKEN_KEY, out var tokenValue) && tokenValue != null) { + Token = tokenValue; + } + + if (AccessTokenResponseParameters.TryGetValue(OAUTH_TOKEN_SECRET_KEY, out var secretValue) && secretValue != null) { + TokenSecret = secretValue; + } + } + + return Token; + } + + /// + /// This method goes through the whole authorize process, including a Authorization window. + /// + /// true if the process is completed + public bool Authorize() { + Token = null; + TokenSecret = null; + Verifier = null; + Log.Debug("Creating Token"); + string requestTokenResponse; + try { + requestTokenResponse = GetRequestToken(); + } catch (Exception ex) { + Log.Error(ex); + throw new NotSupportedException("Service is not available: " + ex.Message); + } + if (string.IsNullOrEmpty(GetAuthorizeToken(requestTokenResponse))) { + Log.Debug("User didn't authenticate!"); + return false; + } + try { + Thread.Sleep(1000); + return GetAccessToken() != null; + } catch (Exception ex) { + Log.Error(ex); + throw; + } + } + + /// + /// Get the link to the authorization page for this application. + /// + /// The url with a valid request token, or a null string. + private string AuthorizationLink => AuthorizeUrl + "?" + OAUTH_TOKEN_KEY + "=" + Token + "&" + OAUTH_CALLBACK_KEY + "=" + UrlEncode3986(CallbackUrl); + + /// + /// Submit a web request using oAuth. + /// + /// GET or POST + /// The full url, including the querystring for the signing/request + /// Parameters for the request, which need to be signed + /// Parameters for the request, which do not need to be signed + /// Data to post (MemoryStream) + /// The web server response. + public string MakeOAuthRequest(HTTPMethod method, string requestUrl, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { + return MakeOAuthRequest(method, requestUrl, requestUrl, null, parametersToSign, additionalParameters, postData); + } + + /// + /// Submit a web request using oAuth. + /// + /// GET or POST + /// The full url, including the querystring for the signing/request + /// Header values + /// Parameters for the request, which need to be signed + /// Parameters for the request, which do not need to be signed + /// Data to post (MemoryStream) + /// The web server response. + public string MakeOAuthRequest(HTTPMethod method, string requestUrl, IDictionary headers, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { + return MakeOAuthRequest(method, requestUrl, requestUrl, headers, parametersToSign, additionalParameters, postData); + } + + /// + /// Submit a web request using oAuth. + /// + /// GET or POST + /// The full url, including the querystring for the signing + /// The full url, including the querystring for the request + /// Parameters for the request, which need to be signed + /// Parameters for the request, which do not need to be signed + /// Data to post (MemoryStream) + /// The web server response. + public string MakeOAuthRequest(HTTPMethod method, string signUrl, string requestUrl, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { + return MakeOAuthRequest(method, signUrl, requestUrl, null, parametersToSign, additionalParameters, postData); + } + + /// + /// Submit a web request using oAuth. + /// + /// GET or POST + /// The full url, including the querystring for the signing + /// The full url, including the querystring for the request + /// Headers for the request + /// Parameters for the request, which need to be signed + /// Parameters for the request, which do not need to be signed + /// Data to post (MemoryStream) + /// The web server response. + public string MakeOAuthRequest(HTTPMethod method, string signUrl, string requestUrl, IDictionary headers, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { + if (parametersToSign == null) { + parametersToSign = new Dictionary(); + } + int retries = 2; + Exception lastException = null; + while (retries-- > 0) { + // If we are not trying to get a Authorization or Accestoken, and we don't have a token, create one + if (string.IsNullOrEmpty(Token)) { + if (!AutoLogin || !Authorize()) { + throw new Exception("Not authorized"); + } + } + try { + Sign(method, signUrl, parametersToSign); + + // Join all parameters + IDictionary newParameters = new Dictionary(); + foreach (var parameter in parametersToSign) { + newParameters.Add(parameter); + } + if (additionalParameters != null) { + foreach (var parameter in additionalParameters) { + newParameters.Add(parameter); + } + } + return MakeRequest(method, requestUrl, headers, newParameters, postData); + } catch (UnauthorizedAccessException uaEx) { + lastException = uaEx; + Token = null; + TokenSecret = null; + // Remove oauth keys, so they aren't added double + List keysToDelete = new List(); + foreach (string parameterKey in parametersToSign.Keys) + { + if (parameterKey.StartsWith(OAUTH_PARAMETER_PREFIX)) + { + keysToDelete.Add(parameterKey); + } + } + foreach (string keyToDelete in keysToDelete) + { + parametersToSign.Remove(keyToDelete); + } + } + } + if (lastException != null) { + throw lastException; + } + throw new Exception("Not authorized"); + } + + /// + /// OAuth sign the parameters, meaning all oauth parameters are added to the supplied dictionary. + /// And additionally a signature is added. + /// + /// Method (POST,PUT,GET) + /// Url to call + /// IDictionary of string and string + private void Sign(HTTPMethod method, string requestUrl, IDictionary parameters) { + if (parameters == null) { + throw new ArgumentNullException(nameof(parameters)); + } + // Build the signature base + StringBuilder signatureBase = new StringBuilder(); + + // Add Method to signature base + signatureBase.Append(method).Append("&"); + + // Add normalized URL + Uri url = new Uri(requestUrl); + string normalizedUrl = string.Format(CultureInfo.InvariantCulture, "{0}://{1}", url.Scheme, url.Host); + if (!((url.Scheme == "http" && url.Port == 80) || (url.Scheme == "https" && url.Port == 443))) { + normalizedUrl += ":" + url.Port; + } + normalizedUrl += url.AbsolutePath; + signatureBase.Append(UrlEncode3986(normalizedUrl)).Append("&"); + + // Add normalized parameters + parameters.Add(OAUTH_VERSION_KEY, OAUTH_VERSION); + parameters.Add(OAUTH_NONCE_KEY, GenerateNonce()); + parameters.Add(OAUTH_TIMESTAMP_KEY, GenerateTimeStamp()); + switch(SignatureType) { + case OAuthSignatureTypes.PLAINTEXT: + parameters.Add(OAUTH_SIGNATURE_METHOD_KEY, PlainTextSignatureType); + break; + default: + parameters.Add(OAUTH_SIGNATURE_METHOD_KEY, HMACSHA1SignatureType); + break; + } + parameters.Add(OAUTH_CONSUMER_KEY_KEY, _consumerKey); + if (CallbackUrl != null && RequestTokenUrl != null && requestUrl.StartsWith(RequestTokenUrl)) { + parameters.Add(OAUTH_CALLBACK_KEY, CallbackUrl); + } + if (!string.IsNullOrEmpty(Verifier)) { + parameters.Add(OAUTH_VERIFIER_KEY, Verifier); + } + if (!string.IsNullOrEmpty(Token)) { + parameters.Add(OAUTH_TOKEN_KEY, Token); + } + signatureBase.Append(UrlEncode3986(GenerateNormalizedParametersString(parameters))); + Log.DebugFormat("Signature base: {0}", signatureBase); + string key = string.Format(CultureInfo.InvariantCulture, "{0}&{1}", UrlEncode3986(_consumerSecret), string.IsNullOrEmpty(TokenSecret) ? string.Empty : UrlEncode3986(TokenSecret)); + switch (SignatureType) { + case OAuthSignatureTypes.PLAINTEXT: + parameters.Add(OAUTH_SIGNATURE_KEY, key); + break; + default: + // Generate Signature and add it to the parameters + HMACSHA1 hmacsha1 = new HMACSHA1 {Key = Encoding.UTF8.GetBytes(key)}; + string signature = ComputeHash(hmacsha1, signatureBase.ToString()); + parameters.Add(OAUTH_SIGNATURE_KEY, signature); + break; + } + } + + /// + /// Make the actual OAuth request, all oauth parameters are passed as header (default) and the others are placed in the url or post data. + /// Any additional parameters added after the Sign call are not in the signature, this could be by design! + /// + /// + /// + /// + /// + /// IBinaryParameter + /// Response from server + private string MakeRequest(HTTPMethod method, string requestUrl, IDictionary headers, IDictionary parameters, IBinaryContainer postData) { + if (parameters == null) { + throw new ArgumentNullException(nameof(parameters)); + } + IDictionary requestParameters; + // Add oAuth values as HTTP headers, if this is allowed + StringBuilder authHeader = null; + if (UseHttpHeadersForAuthorization) { + authHeader = new StringBuilder(); + requestParameters = new Dictionary(); + foreach (string parameterKey in parameters.Keys) { + if (parameterKey.StartsWith(OAUTH_PARAMETER_PREFIX)) { + authHeader.AppendFormat(CultureInfo.InvariantCulture, "{0}=\"{1}\", ", parameterKey, UrlEncode3986($"{parameters[parameterKey]}")); + } else if (!requestParameters.ContainsKey(parameterKey)) { + requestParameters.Add(parameterKey, parameters[parameterKey]); + } + } + // Remove trailing comma and space and add it to the headers + if (authHeader.Length > 0) { + authHeader.Remove(authHeader.Length - 2, 2); + } + } else { + requestParameters = parameters; + } + + if (HTTPMethod.GET == method || postData != null) { + if (requestParameters.Count > 0) { + // Add the parameters to the request + requestUrl += "?" + NetworkHelper.GenerateQueryParameters(requestParameters); + } + } + // Create webrequest + HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(requestUrl, method); + webRequest.ServicePoint.Expect100Continue = false; + webRequest.UserAgent = _userAgent; + + if (UseHttpHeadersForAuthorization && authHeader != null) { + Log.DebugFormat("Authorization: OAuth {0}", authHeader); + webRequest.Headers.Add("Authorization: OAuth " + authHeader); + } + + if (headers != null) { + foreach(string key in headers.Keys) { + webRequest.Headers.Add(key, headers[key]); + } + } + + if ((HTTPMethod.POST == method || HTTPMethod.PUT == method) && postData == null && requestParameters.Count > 0) { + if (UseMultipartFormData) { + NetworkHelper.WriteMultipartFormData(webRequest, requestParameters); + } else { + StringBuilder form = new StringBuilder(); + foreach (string parameterKey in requestParameters.Keys) + { + var binaryParameter = parameters[parameterKey] as IBinaryContainer; + form.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", UrlEncode3986(parameterKey), binaryParameter != null ? UrlEncode3986(binaryParameter.ToBase64String(Base64FormattingOptions.None)) : UrlEncode3986($"{parameters[parameterKey]}")); + } + // Remove trailing & + if (form.Length > 0) { + form.Remove(form.Length - 1, 1); + } + webRequest.ContentType = "application/x-www-form-urlencoded"; + byte[] data = Encoding.UTF8.GetBytes(form.ToString()); + using var requestStream = webRequest.GetRequestStream(); + requestStream.Write(data, 0, data.Length); + } + } else if (postData != null) { + postData.Upload(webRequest); + } else { + webRequest.ContentLength = 0; + } + + string responseData; + try { + responseData = NetworkHelper.GetResponseAsString(webRequest); + Log.DebugFormat("Response: {0}", responseData); + } catch (Exception ex) { + Log.Error("Couldn't retrieve response: ", ex); + throw; + } + + return responseData; + } + } +} \ No newline at end of file diff --git a/GreenshotPlugin/Core/OAuth/OAuthSignatureTypes.cs b/GreenshotPlugin/Core/OAuth/OAuthSignatureTypes.cs new file mode 100644 index 000000000..af2bfc710 --- /dev/null +++ b/GreenshotPlugin/Core/OAuth/OAuthSignatureTypes.cs @@ -0,0 +1,31 @@ +/* + * Greenshot - a free and open source screenshot tool + * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom + * + * For more information see: http://getgreenshot.org/ + * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace GreenshotPlugin.Core.OAuth +{ + /// + /// Provides a predefined set of algorithms that are supported officially by the OAuth 1.x protocol + /// + public enum OAuthSignatureTypes { + HMACSHA1, + PLAINTEXT, + } +} \ No newline at end of file diff --git a/GreenshotPlugin/Core/OAuthHelper.cs b/GreenshotPlugin/Core/OAuthHelper.cs deleted file mode 100644 index ce059189f..000000000 --- a/GreenshotPlugin/Core/OAuthHelper.cs +++ /dev/null @@ -1,1269 +0,0 @@ -/* - * Greenshot - a free and open source screenshot tool - * Copyright (C) 2007-2020 Thomas Braun, Jens Klingen, Robin Krom - * - * For more information see: http://getgreenshot.org/ - * The Greenshot project is hosted on GitHub https://github.com/greenshot/greenshot - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 1 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -using GreenshotPlugin.Controls; -using log4net; -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Diagnostics; -using System.Drawing; -using System.Globalization; -using System.Net; -using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Windows.Forms; -using GreenshotPlugin.Hooking; - -namespace GreenshotPlugin.Core { - /// - /// Provides a predefined set of algorithms that are supported officially by the OAuth 1.x protocol - /// - public enum OAuthSignatureTypes { - HMACSHA1, - PLAINTEXT, - } - - /// - /// Specify the authorize mode that is used to get the token from the cloud service. - /// - public enum OAuth2AuthorizeMode { - Unknown, // Will give an exception, caller needs to specify another value - LocalServer, // Will specify a redirect URL to http://localhost:port/authorize, while having a HttpListener - MonitorTitle, // Not implemented yet: Will monitor for title changes - Pin, // Not implemented yet: Will ask the user to enter the shown PIN - EmbeddedBrowser, // Will open into an embedded _browser (OAuthLoginForm), and catch the redirect - OutOfBoundAuto - } - - /// - /// Settings for the OAuth 2 protocol - /// - public class OAuth2Settings { - public OAuth2Settings() { - AdditionalAttributes = new Dictionary(); - // Create a default state - State = Guid.NewGuid().ToString(); - AuthorizeMode = OAuth2AuthorizeMode.Unknown; - } - - public OAuth2AuthorizeMode AuthorizeMode { - get; - set; - } - - /// - /// Specify the name of the cloud service, so it can be used in window titles, logs etc - /// - public string CloudServiceName { - get; - set; - } - - /// - /// Specify the size of the embedded Browser, if using this - /// - public Size BrowserSize { - get; - set; - } - - /// - /// The OAuth 2 client id - /// - public string ClientId { - get; - set; - } - - /// - /// The OAuth 2 client secret - /// - public string ClientSecret { - get; - set; - } - - /// - /// The OAuth 2 state, this is something that is passed to the server, is not processed but returned back to the client. - /// e.g. a correlation ID - /// Default this is filled with a new Guid - /// - public string State { - get; - set; - } - - /// - /// The autorization URL where the values of this class can be "injected" - /// - public string AuthUrlPattern { - get; - set; - } - - /// - /// Get formatted Auth url (this will call a FormatWith(this) on the AuthUrlPattern - /// - public string FormattedAuthUrl => AuthUrlPattern.FormatWith(this); - - /// - /// The URL to get a Token - /// - public string TokenUrl { - get; - set; - } - - /// - /// 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 - /// - public string RedirectUrl { - get; - set; - } - - /// - /// Bearer token for accessing OAuth 2 services - /// - public string AccessToken { - get; - set; - } - - /// - /// Expire time for the AccessToken, this this time (-60 seconds) is passed a new AccessToken needs to be generated with the RefreshToken - /// - public DateTimeOffset AccessTokenExpires { - get; - set; - } - - /// - /// Return true if the access token is expired. - /// Important "side-effect": if true is returned the AccessToken will be set to null! - /// - public bool IsAccessTokenExpired { - get { - bool expired = true; - if (!string.IsNullOrEmpty(AccessToken)) { - expired = DateTimeOffset.Now.AddSeconds(60) > AccessTokenExpires; - } - // Make sure the token is not usable - if (expired) { - AccessToken = null; - } - return expired; - } - } - - /// - /// Token used to get a new Access Token - /// - public string RefreshToken { - get; - set; - } - - /// - /// Put anything in here which is needed for the OAuth 2 implementation of this specific service but isn't generic, e.g. for Google there is a "scope" - /// - public IDictionary AdditionalAttributes { - 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; - } - } - - /// - /// An OAuth 1 session object - /// - public class OAuthSession { - private static readonly ILog Log = LogManager.GetLogger(typeof(OAuthSession)); - protected const string OAUTH_VERSION = "1.0"; - protected const string OAUTH_PARAMETER_PREFIX = "oauth_"; - - // - // List of know and used oauth parameters' names - // - protected const string OAUTH_CONSUMER_KEY_KEY = "oauth_consumer_key"; - protected const string OAUTH_CALLBACK_KEY = "oauth_callback"; - protected const string OAUTH_VERSION_KEY = "oauth_version"; - protected const string OAUTH_SIGNATURE_METHOD_KEY = "oauth_signature_method"; - protected const string OAUTH_TIMESTAMP_KEY = "oauth_timestamp"; - protected const string OAUTH_NONCE_KEY = "oauth_nonce"; - protected const string OAUTH_TOKEN_KEY = "oauth_token"; - protected const string OAUTH_VERIFIER_KEY = "oauth_verifier"; - protected const string OAUTH_TOKEN_SECRET_KEY = "oauth_token_secret"; - protected const string OAUTH_SIGNATURE_KEY = "oauth_signature"; - - protected const string HMACSHA1SignatureType = "HMAC-SHA1"; - protected const string PlainTextSignatureType = "PLAINTEXT"; - - protected static Random random = new Random(); - - protected const string UnreservedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~"; - - private string _userAgent = "Greenshot"; - private IDictionary _requestTokenResponseParameters; - - public IDictionary RequestTokenParameters { get; } = new Dictionary(); - - /// - /// Parameters of the last called getAccessToken - /// - public IDictionary AccessTokenResponseParameters { get; private set; } - - /// - /// Parameters of the last called getRequestToken - /// - public IDictionary RequestTokenResponseParameters => _requestTokenResponseParameters; - - private readonly string _consumerKey; - private readonly string _consumerSecret; - - // default _browser size - - public HTTPMethod RequestTokenMethod { - get; - set; - } - public HTTPMethod AccessTokenMethod { - get; - set; - } - public string RequestTokenUrl { - get; - set; - } - public string AuthorizeUrl { - get; - set; - } - public string AccessTokenUrl { - get; - set; - } - public string Token { - get; - set; - } - public string TokenSecret { - get; - set; - } - public string Verifier { - get; - set; - } - public OAuthSignatureTypes SignatureType { - get; - set; - } - - public bool UseMultipartFormData { get; set; } - public string UserAgent { - get { - return _userAgent; - } - set { - _userAgent = value; - } - } - public string CallbackUrl { get; set; } = "http://getgreenshot.org"; - - public bool CheckVerifier { get; set; } = true; - - public Size BrowserSize { get; set; } = new Size(864, 587); - - public string LoginTitle { get; set; } = "Authorize Greenshot access"; - - public bool UseHttpHeadersForAuthorization { get; set; } = true; - - public bool AutoLogin { - get; - set; - } - - /// - /// Create an OAuthSession with the consumerKey / consumerSecret - /// - /// "Public" key for the encoding. When using RSASHA1 this is the path to the private key file - /// "Private" key for the encoding. when usin RSASHA1 this is the password for the private key file - public OAuthSession(string consumerKey, string consumerSecret) { - _consumerKey = consumerKey; - _consumerSecret = consumerSecret; - UseMultipartFormData = true; - RequestTokenMethod = HTTPMethod.GET; - AccessTokenMethod = HTTPMethod.GET; - SignatureType = OAuthSignatureTypes.HMACSHA1; - AutoLogin = true; - } - - /// - /// Helper function to compute a hash value - /// - /// The hashing algorithm used. If that algorithm needs some initialization, like HMAC and its derivatives, they should be initialized prior to passing it to this function - /// The data to hash - /// a Base64 string of the hash value - private static string ComputeHash(HashAlgorithm hashAlgorithm, string data) { - if (hashAlgorithm == null) { - throw new ArgumentNullException(nameof(hashAlgorithm)); - } - - if (string.IsNullOrEmpty(data)) { - throw new ArgumentNullException(nameof(data)); - } - - byte[] dataBuffer = Encoding.UTF8.GetBytes(data); - byte[] hashBytes = hashAlgorithm.ComputeHash(dataBuffer); - - return Convert.ToBase64String(hashBytes); - } - - /// - /// Generate the normalized paramter string - /// - /// the list of query parameters - /// a string with the normalized query parameters - private static string GenerateNormalizedParametersString(IDictionary queryParameters) { - if (queryParameters == null || queryParameters.Count == 0) { - return string.Empty; - } - - queryParameters = new SortedDictionary(queryParameters); - - StringBuilder sb = new StringBuilder(); - foreach (string key in queryParameters.Keys) { - if (queryParameters[key] is string) { - sb.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", key, UrlEncode3986($"{queryParameters[key]}")); - } - } - sb.Remove(sb.Length - 1, 1); - - return sb.ToString(); - } - - /// - /// This is a different Url Encode implementation since the default .NET one outputs the percent encoding in lower case. - /// While this is not a problem with the percent encoding spec, it is used in upper case throughout OAuth - /// The resulting string is for UTF-8 encoding! - /// - /// The value to Url encode - /// Returns a Url encoded string (unicode) with UTF-8 encoded % values - public static string UrlEncode3986(string value) { - StringBuilder result = new StringBuilder(); - - foreach (char symbol in value) { - if (UnreservedChars.IndexOf(symbol) != -1) { - result.Append(symbol); - } else { - byte[] utf8Bytes = Encoding.UTF8.GetBytes(symbol.ToString()); - foreach(byte utf8Byte in utf8Bytes) { - result.AppendFormat("%{0:X2}", utf8Byte); - } - } - } - - return result.ToString(); - } - - /// - /// Generate the timestamp for the signature - /// - /// - public static string GenerateTimeStamp() { - // Default implementation of UNIX time of the current UTC time - TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0); - return Convert.ToInt64(ts.TotalSeconds).ToString(); - } - - /// - /// Generate a nonce - /// - /// - public static string GenerateNonce() { - // Just a simple implementation of a random number between 123400 and 9999999 - return random.Next(123400, 9999999).ToString(); - } - - /// - /// Get the request token using the consumer key and secret. Also initializes tokensecret - /// - /// response, this doesn't need to be used!! - private string GetRequestToken() { - IDictionary parameters = new Dictionary(); - foreach(var value in RequestTokenParameters) { - parameters.Add(value); - } - Sign(RequestTokenMethod, RequestTokenUrl, parameters); - string response = MakeRequest(RequestTokenMethod, RequestTokenUrl, null, parameters, null); - if (!string.IsNullOrEmpty(response)) { - response = NetworkHelper.UrlDecode(response); - Log.DebugFormat("Request token response: {0}", response); - _requestTokenResponseParameters = NetworkHelper.ParseQueryString(response); - if (_requestTokenResponseParameters.TryGetValue(OAUTH_TOKEN_KEY, out var value)) { - Token = value; - TokenSecret = _requestTokenResponseParameters[OAUTH_TOKEN_SECRET_KEY]; - } - } - return response; - } - - /// - /// Authorize the token by showing the dialog - /// - /// Pass the response from the server's request token, so if there is something wrong we can show it. - /// The request token. - private string GetAuthorizeToken(string requestTokenResponse) { - if (string.IsNullOrEmpty(Token)) { - Exception e = new Exception("The request token is not set, service responded with: " + requestTokenResponse); - throw e; - } - Log.DebugFormat("Opening AuthorizationLink: {0}", AuthorizationLink); - OAuthLoginForm oAuthLoginForm = new OAuthLoginForm(LoginTitle, BrowserSize, AuthorizationLink, CallbackUrl); - oAuthLoginForm.ShowDialog(); - if (oAuthLoginForm.IsOk) { - if (oAuthLoginForm.CallbackParameters != null) { - if (oAuthLoginForm.CallbackParameters.TryGetValue(OAUTH_TOKEN_KEY, out var tokenValue)) { - Token = tokenValue; - } - - if (oAuthLoginForm.CallbackParameters.TryGetValue(OAUTH_VERIFIER_KEY, out var verifierValue)) { - Verifier = verifierValue; - } - } - } - if (CheckVerifier) { - if (!string.IsNullOrEmpty(Verifier)) { - return Token; - } - return null; - } - return Token; - } - - /// - /// Get the access token - /// - /// The access token. - private string GetAccessToken() { - if (string.IsNullOrEmpty(Token) || (CheckVerifier && string.IsNullOrEmpty(Verifier))) { - Exception e = new Exception("The request token and verifier were not set"); - throw e; - } - - IDictionary parameters = new Dictionary(); - Sign(AccessTokenMethod, AccessTokenUrl, parameters); - string response = MakeRequest(AccessTokenMethod, AccessTokenUrl, null, parameters, null); - if (!string.IsNullOrEmpty(response)) { - response = NetworkHelper.UrlDecode(response); - Log.DebugFormat("Access token response: {0}", response); - AccessTokenResponseParameters = NetworkHelper.ParseQueryString(response); - if (AccessTokenResponseParameters.TryGetValue(OAUTH_TOKEN_KEY, out var tokenValue) && tokenValue != null) { - Token = tokenValue; - } - - if (AccessTokenResponseParameters.TryGetValue(OAUTH_TOKEN_SECRET_KEY, out var secretValue) && secretValue != null) { - TokenSecret = secretValue; - } - } - - return Token; - } - - /// - /// This method goes through the whole authorize process, including a Authorization window. - /// - /// true if the process is completed - public bool Authorize() { - Token = null; - TokenSecret = null; - Verifier = null; - Log.Debug("Creating Token"); - string requestTokenResponse; - try { - requestTokenResponse = GetRequestToken(); - } catch (Exception ex) { - Log.Error(ex); - throw new NotSupportedException("Service is not available: " + ex.Message); - } - if (string.IsNullOrEmpty(GetAuthorizeToken(requestTokenResponse))) { - Log.Debug("User didn't authenticate!"); - return false; - } - try { - Thread.Sleep(1000); - return GetAccessToken() != null; - } catch (Exception ex) { - Log.Error(ex); - throw; - } - } - - /// - /// Get the link to the authorization page for this application. - /// - /// The url with a valid request token, or a null string. - private string AuthorizationLink => AuthorizeUrl + "?" + OAUTH_TOKEN_KEY + "=" + Token + "&" + OAUTH_CALLBACK_KEY + "=" + UrlEncode3986(CallbackUrl); - - /// - /// Submit a web request using oAuth. - /// - /// GET or POST - /// The full url, including the querystring for the signing/request - /// Parameters for the request, which need to be signed - /// Parameters for the request, which do not need to be signed - /// Data to post (MemoryStream) - /// The web server response. - public string MakeOAuthRequest(HTTPMethod method, string requestUrl, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { - return MakeOAuthRequest(method, requestUrl, requestUrl, null, parametersToSign, additionalParameters, postData); - } - - /// - /// Submit a web request using oAuth. - /// - /// GET or POST - /// The full url, including the querystring for the signing/request - /// Header values - /// Parameters for the request, which need to be signed - /// Parameters for the request, which do not need to be signed - /// Data to post (MemoryStream) - /// The web server response. - public string MakeOAuthRequest(HTTPMethod method, string requestUrl, IDictionary headers, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { - return MakeOAuthRequest(method, requestUrl, requestUrl, headers, parametersToSign, additionalParameters, postData); - } - - /// - /// Submit a web request using oAuth. - /// - /// GET or POST - /// The full url, including the querystring for the signing - /// The full url, including the querystring for the request - /// Parameters for the request, which need to be signed - /// Parameters for the request, which do not need to be signed - /// Data to post (MemoryStream) - /// The web server response. - public string MakeOAuthRequest(HTTPMethod method, string signUrl, string requestUrl, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { - return MakeOAuthRequest(method, signUrl, requestUrl, null, parametersToSign, additionalParameters, postData); - } - - /// - /// Submit a web request using oAuth. - /// - /// GET or POST - /// The full url, including the querystring for the signing - /// The full url, including the querystring for the request - /// Headers for the request - /// Parameters for the request, which need to be signed - /// Parameters for the request, which do not need to be signed - /// Data to post (MemoryStream) - /// The web server response. - public string MakeOAuthRequest(HTTPMethod method, string signUrl, string requestUrl, IDictionary headers, IDictionary parametersToSign, IDictionary additionalParameters, IBinaryContainer postData) { - if (parametersToSign == null) { - parametersToSign = new Dictionary(); - } - int retries = 2; - Exception lastException = null; - while (retries-- > 0) { - // If we are not trying to get a Authorization or Accestoken, and we don't have a token, create one - if (string.IsNullOrEmpty(Token)) { - if (!AutoLogin || !Authorize()) { - throw new Exception("Not authorized"); - } - } - try { - Sign(method, signUrl, parametersToSign); - - // Join all parameters - IDictionary newParameters = new Dictionary(); - foreach (var parameter in parametersToSign) { - newParameters.Add(parameter); - } - if (additionalParameters != null) { - foreach (var parameter in additionalParameters) { - newParameters.Add(parameter); - } - } - return MakeRequest(method, requestUrl, headers, newParameters, postData); - } catch (UnauthorizedAccessException uaEx) { - lastException = uaEx; - Token = null; - TokenSecret = null; - // Remove oauth keys, so they aren't added double - List keysToDelete = new List(); - foreach (string parameterKey in parametersToSign.Keys) - { - if (parameterKey.StartsWith(OAUTH_PARAMETER_PREFIX)) - { - keysToDelete.Add(parameterKey); - } - } - foreach (string keyToDelete in keysToDelete) - { - parametersToSign.Remove(keyToDelete); - } - } - } - if (lastException != null) { - throw lastException; - } - throw new Exception("Not authorized"); - } - - /// - /// OAuth sign the parameters, meaning all oauth parameters are added to the supplied dictionary. - /// And additionally a signature is added. - /// - /// Method (POST,PUT,GET) - /// Url to call - /// IDictionary of string and string - private void Sign(HTTPMethod method, string requestUrl, IDictionary parameters) { - if (parameters == null) { - throw new ArgumentNullException(nameof(parameters)); - } - // Build the signature base - StringBuilder signatureBase = new StringBuilder(); - - // Add Method to signature base - signatureBase.Append(method).Append("&"); - - // Add normalized URL - Uri url = new Uri(requestUrl); - string normalizedUrl = string.Format(CultureInfo.InvariantCulture, "{0}://{1}", url.Scheme, url.Host); - if (!((url.Scheme == "http" && url.Port == 80) || (url.Scheme == "https" && url.Port == 443))) { - normalizedUrl += ":" + url.Port; - } - normalizedUrl += url.AbsolutePath; - signatureBase.Append(UrlEncode3986(normalizedUrl)).Append("&"); - - // Add normalized parameters - parameters.Add(OAUTH_VERSION_KEY, OAUTH_VERSION); - parameters.Add(OAUTH_NONCE_KEY, GenerateNonce()); - parameters.Add(OAUTH_TIMESTAMP_KEY, GenerateTimeStamp()); - switch(SignatureType) { - case OAuthSignatureTypes.PLAINTEXT: - parameters.Add(OAUTH_SIGNATURE_METHOD_KEY, PlainTextSignatureType); - break; - default: - parameters.Add(OAUTH_SIGNATURE_METHOD_KEY, HMACSHA1SignatureType); - break; - } - parameters.Add(OAUTH_CONSUMER_KEY_KEY, _consumerKey); - if (CallbackUrl != null && RequestTokenUrl != null && requestUrl.StartsWith(RequestTokenUrl)) { - parameters.Add(OAUTH_CALLBACK_KEY, CallbackUrl); - } - if (!string.IsNullOrEmpty(Verifier)) { - parameters.Add(OAUTH_VERIFIER_KEY, Verifier); - } - if (!string.IsNullOrEmpty(Token)) { - parameters.Add(OAUTH_TOKEN_KEY, Token); - } - signatureBase.Append(UrlEncode3986(GenerateNormalizedParametersString(parameters))); - Log.DebugFormat("Signature base: {0}", signatureBase); - string key = string.Format(CultureInfo.InvariantCulture, "{0}&{1}", UrlEncode3986(_consumerSecret), string.IsNullOrEmpty(TokenSecret) ? string.Empty : UrlEncode3986(TokenSecret)); - switch (SignatureType) { - case OAuthSignatureTypes.PLAINTEXT: - parameters.Add(OAUTH_SIGNATURE_KEY, key); - break; - default: - // Generate Signature and add it to the parameters - HMACSHA1 hmacsha1 = new HMACSHA1 {Key = Encoding.UTF8.GetBytes(key)}; - string signature = ComputeHash(hmacsha1, signatureBase.ToString()); - parameters.Add(OAUTH_SIGNATURE_KEY, signature); - break; - } - } - - /// - /// Make the actual OAuth request, all oauth parameters are passed as header (default) and the others are placed in the url or post data. - /// Any additional parameters added after the Sign call are not in the signature, this could be by design! - /// - /// - /// - /// - /// - /// IBinaryParameter - /// Response from server - private string MakeRequest(HTTPMethod method, string requestUrl, IDictionary headers, IDictionary parameters, IBinaryContainer postData) { - if (parameters == null) { - throw new ArgumentNullException(nameof(parameters)); - } - IDictionary requestParameters; - // Add oAuth values as HTTP headers, if this is allowed - StringBuilder authHeader = null; - if (UseHttpHeadersForAuthorization) { - authHeader = new StringBuilder(); - requestParameters = new Dictionary(); - foreach (string parameterKey in parameters.Keys) { - if (parameterKey.StartsWith(OAUTH_PARAMETER_PREFIX)) { - authHeader.AppendFormat(CultureInfo.InvariantCulture, "{0}=\"{1}\", ", parameterKey, UrlEncode3986($"{parameters[parameterKey]}")); - } else if (!requestParameters.ContainsKey(parameterKey)) { - requestParameters.Add(parameterKey, parameters[parameterKey]); - } - } - // Remove trailing comma and space and add it to the headers - if (authHeader.Length > 0) { - authHeader.Remove(authHeader.Length - 2, 2); - } - } else { - requestParameters = parameters; - } - - if (HTTPMethod.GET == method || postData != null) { - if (requestParameters.Count > 0) { - // Add the parameters to the request - requestUrl += "?" + NetworkHelper.GenerateQueryParameters(requestParameters); - } - } - // Create webrequest - HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(requestUrl, method); - webRequest.ServicePoint.Expect100Continue = false; - webRequest.UserAgent = _userAgent; - - if (UseHttpHeadersForAuthorization && authHeader != null) { - Log.DebugFormat("Authorization: OAuth {0}", authHeader); - webRequest.Headers.Add("Authorization: OAuth " + authHeader); - } - - if (headers != null) { - foreach(string key in headers.Keys) { - webRequest.Headers.Add(key, headers[key]); - } - } - - if ((HTTPMethod.POST == method || HTTPMethod.PUT == method) && postData == null && requestParameters.Count > 0) { - if (UseMultipartFormData) { - NetworkHelper.WriteMultipartFormData(webRequest, requestParameters); - } else { - StringBuilder form = new StringBuilder(); - foreach (string parameterKey in requestParameters.Keys) - { - var binaryParameter = parameters[parameterKey] as IBinaryContainer; - form.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}&", UrlEncode3986(parameterKey), binaryParameter != null ? UrlEncode3986(binaryParameter.ToBase64String(Base64FormattingOptions.None)) : UrlEncode3986($"{parameters[parameterKey]}")); - } - // Remove trailing & - if (form.Length > 0) { - form.Remove(form.Length - 1, 1); - } - webRequest.ContentType = "application/x-www-form-urlencoded"; - byte[] data = Encoding.UTF8.GetBytes(form.ToString()); - using var requestStream = webRequest.GetRequestStream(); - requestStream.Write(data, 0, data.Length); - } - } else if (postData != null) { - postData.Upload(webRequest); - } else { - webRequest.ContentLength = 0; - } - - string responseData; - try { - responseData = NetworkHelper.GetResponseAsString(webRequest); - Log.DebugFormat("Response: {0}", responseData); - } catch (Exception ex) { - Log.Error("Couldn't retrieve response: ", ex); - throw; - } - - return responseData; - } - } - - /// - /// OAuth 2.0 verification code receiver that runs a local server on a free port - /// and waits for a call with the authorization verification code. - /// - public class LocalServerCodeReceiver { - private static readonly ILog Log = LogManager.GetLogger(typeof(LocalServerCodeReceiver)); - private readonly ManualResetEvent _ready = new ManualResetEvent(true); - - /// - /// The call back format. Expects one port parameter. - /// Default: http://localhost:{0}/authorize/ - /// - public string LoopbackCallbackUrl { get; set; } = "http://localhost:{0}/authorize/"; - - /// - /// HTML code to to return the _browser, default it will try to close the _browser / tab, this won't always work. - /// You can use CloudServiceName where you want to show the CloudServiceName from your OAuth2 settings - /// - public string ClosePageResponse { get; set; } = @" -OAuth 2.0 Authentication CloudServiceName - -Greenshot received information from CloudServiceName. You can close this browser / tab if it is not closed itself... - - -"; - - private string _redirectUri; - /// - /// The URL to redirect to - /// - protected string RedirectUri { - get { - if (!string.IsNullOrEmpty(_redirectUri)) { - return _redirectUri; - } - - return _redirectUri = string.Format(LoopbackCallbackUrl, GetRandomUnusedPort()); - } - } - - private string _cloudServiceName; - - private readonly IDictionary _returnValues = new Dictionary(); - - - /// - /// The OAuth code receiver - /// - /// - /// Dictionary with values - public IDictionary ReceiveCode(OAuth2Settings oauth2Settings) { - // Set the redirect URL on the settings - oauth2Settings.RedirectUrl = RedirectUri; - _cloudServiceName = oauth2Settings.CloudServiceName; - using (var listener = new HttpListener()) { - listener.Prefixes.Add(oauth2Settings.RedirectUrl); - try { - listener.Start(); - - // Get the formatted FormattedAuthUrl - string authorizationUrl = oauth2Settings.FormattedAuthUrl; - Log.DebugFormat("Open a browser with: {0}", authorizationUrl); - Process.Start(authorizationUrl); - - // Wait to get the authorization code response. - var context = listener.BeginGetContext(ListenerCallback, listener); - _ready.Reset(); - - while (!context.AsyncWaitHandle.WaitOne(1000, true)) { - Log.Debug("Waiting for response"); - } - } catch (Exception) { - // Make sure we can clean up, also if the thead is aborted - _ready.Set(); - throw; - } finally { - _ready.WaitOne(); - listener.Close(); - } - } - return _returnValues; - } - - /// - /// Handle a connection async, this allows us to break the waiting - /// - /// IAsyncResult - private void ListenerCallback(IAsyncResult result) { - HttpListener listener = (HttpListener)result.AsyncState; - - //If not listening return immediately as this method is called one last time after Close() - if (!listener.IsListening) { - return; - } - - // Use EndGetContext to complete the asynchronous operation. - HttpListenerContext context = listener.EndGetContext(result); - - - // Handle request - HttpListenerRequest request = context.Request; - try { - NameValueCollection nameValueCollection = request.QueryString; - - // Get response object. - using (HttpListenerResponse response = context.Response) { - // Write a "close" response. - byte[] buffer = Encoding.UTF8.GetBytes(ClosePageResponse.Replace("CloudServiceName", _cloudServiceName)); - // Write to response stream. - response.ContentLength64 = buffer.Length; - using var stream = response.OutputStream; - stream.Write(buffer, 0, buffer.Length); - } - - // Create a new response URL with a dictionary that contains all the response query parameters. - foreach (var name in nameValueCollection.AllKeys) { - if (!_returnValues.ContainsKey(name)) { - _returnValues.Add(name, nameValueCollection[name]); - } - } - } catch (Exception) { - context.Response.OutputStream.Close(); - throw; - } - _ready.Set(); - } - - /// - /// Returns a random, unused port. - /// - /// port to use - private static int GetRandomUnusedPort() { - var listener = new TcpListener(IPAddress.Loopback, 0); - try { - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } finally { - listener.Stop(); - } - } - } - - /// - /// Code to simplify OAuth 2 - /// - public static class OAuth2Helper { - private const string RefreshToken = "refresh_token"; - private const string AccessToken = "access_token"; - private const string Code = "code"; - private const string ClientId = "client_id"; - private const string ClientSecret = "client_secret"; - private const string GrantType = "grant_type"; - private const string AuthorizationCode = "authorization_code"; - private const string RedirectUri = "redirect_uri"; - private const string ExpiresIn = "expires_in"; - - /// - /// Generate an OAuth 2 Token by using the supplied code - /// - /// OAuth2Settings to update with the information that was retrieved - public static void GenerateRefreshToken(OAuth2Settings settings) { - IDictionary data = new Dictionary - { - // Use the returned code to get a refresh code - { Code, settings.Code }, - { ClientId, settings.ClientId }, - { RedirectUri, settings.RedirectUrl }, - { ClientSecret, settings.ClientSecret }, - { GrantType, AuthorizationCode } - }; - 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); - - IDictionary refreshTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); - if (refreshTokenResult.ContainsKey("error")) - { - if (refreshTokenResult.ContainsKey("error_description")) { - throw new Exception($"{refreshTokenResult["error"]} - {refreshTokenResult["error_description"]}"); - } - 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" - if (refreshTokenResult.ContainsKey(AccessToken)) - { - settings.AccessToken = (string)refreshTokenResult[AccessToken]; - } - if (refreshTokenResult.ContainsKey(RefreshToken)) - { - settings.RefreshToken = (string)refreshTokenResult[RefreshToken]; - } - if (refreshTokenResult.ContainsKey(ExpiresIn)) - { - object seconds = refreshTokenResult[ExpiresIn]; - if (seconds != null) - { - settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double)seconds); - } - } - settings.Code = null; - } - - /// - /// Used to update the settings with the callback information - /// - /// OAuth2Settings - /// IDictionary - /// true if the access token is already in the callback - private static bool UpdateFromCallback(OAuth2Settings settings, IDictionary callbackParameters) - { - if (!callbackParameters.ContainsKey(AccessToken)) - { - return false; - } - if (callbackParameters.ContainsKey(RefreshToken)) - { - // Refresh the refresh token :) - settings.RefreshToken = callbackParameters[RefreshToken]; - } - if (callbackParameters.ContainsKey(ExpiresIn)) - { - var expiresIn = callbackParameters[ExpiresIn]; - settings.AccessTokenExpires = DateTimeOffset.MaxValue; - if (expiresIn != null) - { - if (double.TryParse(expiresIn, out var seconds)) - { - settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds(seconds); - } - } - } - settings.AccessToken = callbackParameters[AccessToken]; - return true; - } - - /// - /// Go out and retrieve a new access token via refresh-token with the TokenUrl in the settings - /// Will upate the access token, refresh token, expire date - /// - /// - public static void GenerateAccessToken(OAuth2Settings settings) { - IDictionary data = new Dictionary - { - { RefreshToken, settings.RefreshToken }, - { ClientId, settings.ClientId }, - { ClientSecret, settings.ClientSecret }, - { GrantType, RefreshToken } - }; - 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); - - // gives as described here: https://developers.google.com/identity/protocols/OAuth2InstalledApp - // "access_token":"1/fFAGRNJru1FTz70BzhT3Zg", - // "expires_in":3920, - // "token_type":"Bearer", - - IDictionary accessTokenResult = JSONHelper.JsonDecode(accessTokenJsonResult); - 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($"{accessTokenResult["error"]} - {accessTokenResult["error_description"]}"); - } else { - throw new Exception((string)accessTokenResult["error"]); - } - } - } - - if (accessTokenResult.ContainsKey(AccessToken)) - { - settings.AccessToken = (string) accessTokenResult[AccessToken]; - settings.AccessTokenExpires = DateTimeOffset.MaxValue; - } - if (accessTokenResult.ContainsKey(RefreshToken)) { - // Refresh the refresh token :) - settings.RefreshToken = (string)accessTokenResult[RefreshToken]; - } - if (accessTokenResult.ContainsKey(ExpiresIn)) - { - object seconds = accessTokenResult[ExpiresIn]; - if (seconds != null) - { - settings.AccessTokenExpires = DateTimeOffset.Now.AddSeconds((double) seconds); - } - } - } - - /// - /// Authenticate by using the mode specified in the settings - /// - /// OAuth2Settings - /// false if it was canceled, true if it worked, exception if not - public static bool Authenticate(OAuth2Settings settings) { - var completed = settings.AuthorizeMode switch - { - OAuth2AuthorizeMode.LocalServer => AuthenticateViaLocalServer(settings), - OAuth2AuthorizeMode.EmbeddedBrowser => AuthenticateViaEmbeddedBrowser(settings), - OAuth2AuthorizeMode.OutOfBoundAuto => AuthenticateViaDefaultBrowser(settings), - _ => throw new NotImplementedException($"Authorize mode '{settings.AuthorizeMode}' is not 'yet' implemented."), - }; - return completed; - } - - /// - /// Authenticate via the default browser - /// If this works, return the code - /// - /// OAuth2Settings with the Auth / Token url etc - /// true if completed, false if canceled - private static bool AuthenticateViaDefaultBrowser(OAuth2Settings settings) - { - var monitor = new WindowsTitleMonitor(); - - string[] code = new string[1]; - monitor.TitleChangeEvent += args => - { - if (args.Title.Contains(settings.State)) - { - code[0] = args.Title; - settings.Code = args.Title; - } - }; - using (var process = Process.Start(settings.FormattedAuthUrl)) - { - while (string.IsNullOrEmpty(code[0])) - { - Application.DoEvents(); - } - }; - - return true; - } - - /// - /// Authenticate via an embedded browser - /// If this works, return the code - /// - /// OAuth2Settings with the Auth / Token url etc - /// true if completed, false if canceled - private static bool AuthenticateViaEmbeddedBrowser(OAuth2Settings settings) { - if (string.IsNullOrEmpty(settings.CloudServiceName)) { - throw new ArgumentNullException(nameof(settings.CloudServiceName)); - } - if (settings.BrowserSize == Size.Empty) { - throw new ArgumentNullException(nameof(settings.BrowserSize)); - } - OAuthLoginForm loginForm = new OAuthLoginForm($"Authorize {settings.CloudServiceName}", settings.BrowserSize, settings.FormattedAuthUrl, settings.RedirectUrl); - loginForm.ShowDialog(); - if (loginForm.IsOk) { - if (loginForm.CallbackParameters.TryGetValue(Code, out var code) && !string.IsNullOrEmpty(code)) { - settings.Code = code; - GenerateRefreshToken(settings); - return true; - } - return UpdateFromCallback(settings, loginForm.CallbackParameters); - } - return false; - } - - /// - /// Authenticate via a local server by using the LocalServerCodeReceiver - /// If this works, return the code - /// - /// OAuth2Settings with the Auth / Token url etc - /// true if completed - private static bool AuthenticateViaLocalServer(OAuth2Settings settings) { - var codeReceiver = new LocalServerCodeReceiver(); - IDictionary result = codeReceiver.ReceiveCode(settings); - - if (result.TryGetValue(Code, out var code) && !string.IsNullOrEmpty(code)) { - settings.Code = code; - GenerateRefreshToken(settings); - return true; - } - - if (result.TryGetValue("error", out var error)) { - if (result.TryGetValue("error_description", out var errorDescription)) { - throw new Exception(errorDescription); - } - if ("access_denied" == error) { - throw new UnauthorizedAccessException("Access denied"); - } - throw new Exception(error); - } - return false; - } - - /// - /// Simple helper to add the Authorization Bearer header - /// - /// WebRequest - /// OAuth2Settings - public static void AddOAuth2Credentials(HttpWebRequest webRequest, OAuth2Settings settings) { - if (!string.IsNullOrEmpty(settings.AccessToken)) { - webRequest.Headers.Add("Authorization", "Bearer " + settings.AccessToken); - } - } - - /// - /// Check and authenticate or refresh tokens - /// - /// OAuth2Settings - public static void CheckAndAuthenticateOrRefresh(OAuth2Settings settings) { - // Get Refresh / Access token - if (string.IsNullOrEmpty(settings.RefreshToken)) { - if (!Authenticate(settings)) { - throw new Exception("Authentication cancelled"); - } - } - if (settings.IsAccessTokenExpired) { - 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 - /// - /// HTTPMethod - /// - /// OAuth2Settings - /// HttpWebRequest - public static HttpWebRequest CreateOAuth2WebRequest(HTTPMethod method, string url, OAuth2Settings settings) { - CheckAndAuthenticateOrRefresh(settings); - - HttpWebRequest webRequest = NetworkHelper.CreateWebRequest(url, method); - AddOAuth2Credentials(webRequest, settings); - return webRequest; - } - } -}