/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU Lesser General Public License * (version 2.1 or later). See the COPYING file in this distribution. */ namespace Publishing.Authenticator.Shotwell.Flickr { internal const string ENDPOINT_URL = "https://api.flickr.com/services/rest"; internal const string EXPIRED_SESSION_ERROR_CODE = "98"; internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\"; internal class Transaction : Publishing.RESTSupport.Transaction { public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) { base(session, method); setup_arguments(); } public Transaction.with_uri(Session session, string uri, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) { base.with_endpoint_url(session, uri, method); setup_arguments(); } private void setup_arguments() { var session = (Session) get_parent_session(); add_argument("oauth_nonce", session.get_oauth_nonce()); add_argument("oauth_signature_method", "HMAC-SHA1"); add_argument("oauth_version", "1.0"); add_argument("oauth_callback", "oob"); add_argument("oauth_timestamp", session.get_oauth_timestamp()); add_argument("oauth_consumer_key", session.get_consumer_key()); } public override void execute() throws Spit.Publishing.PublishingError { ((Session) get_parent_session()).sign_transaction(this); base.execute(); } } internal class Session : Publishing.RESTSupport.Session { private string? request_phase_token = null; private string? request_phase_token_secret = null; private string? access_phase_token = null; private string? access_phase_token_secret = null; private string? username = null; private string? consumer_key = null; private string? consumer_secret = null; public Session() { base(); } public override bool is_authenticated() { return (access_phase_token != null && access_phase_token_secret != null && username != null); } public void authenticate_from_persistent_credentials(string token, string secret, string username) { this.access_phase_token = token; this.access_phase_token_secret = secret; this.username = username; this.authenticated(); } public void deauthenticate() { access_phase_token = null; access_phase_token_secret = null; username = null; } public void set_api_credentials(string consumer_key, string consumer_secret) { this.consumer_key = consumer_key; this.consumer_secret = consumer_secret; } public void sign_transaction(Publishing.RESTSupport.Transaction txn) { string http_method = txn.get_method().to_string(); debug("signing transaction with parameters:"); debug("HTTP method = " + http_method); Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments(); Publishing.RESTSupport.Argument[] sorted_args = Publishing.RESTSupport.Argument.sort(base_string_arguments); string arguments_string = ""; for (int i = 0; i < sorted_args.length; i++) { arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value); if (i < sorted_args.length - 1) arguments_string += "&"; } string? signing_key = null; if (access_phase_token_secret != null) { debug("access phase token secret available; using it as signing key"); signing_key = consumer_secret + "&" + access_phase_token_secret; } else if (request_phase_token_secret != null) { debug("request phase token secret available; using it as signing key"); signing_key = consumer_secret + "&" + request_phase_token_secret; } else { debug("neither access phase nor request phase token secrets available; using API " + "key as signing key"); signing_key = consumer_secret + "&"; } string signature_base_string = http_method + "&" + Soup.URI.encode( txn.get_endpoint_url(), ENCODE_RFC_3986_EXTRA) + "&" + Soup.URI.encode(arguments_string, ENCODE_RFC_3986_EXTRA); debug("signature base string = '%s'", signature_base_string); debug("signing key = '%s'", signing_key); // compute the signature string signature = RESTSupport.hmac_sha1(signing_key, signature_base_string); signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA); debug("signature = '%s'", signature); txn.add_argument("oauth_signature", signature); } public void set_request_phase_credentials(string token, string secret) { this.request_phase_token = token; this.request_phase_token_secret = secret; } public void set_access_phase_credentials(string token, string secret, string username) { this.access_phase_token = token; this.access_phase_token_secret = secret; this.username = username; authenticated(); } public string get_oauth_nonce() { TimeVal currtime = TimeVal(); currtime.get_current_time(); return Checksum.compute_for_string(ChecksumType.MD5, currtime.tv_sec.to_string() + currtime.tv_usec.to_string()); } public string get_oauth_timestamp() { return GLib.get_real_time().to_string().substring(0, 10); } public string get_consumer_key() { assert(consumer_key != null); return consumer_key; } public string get_request_phase_token() { assert(request_phase_token != null); return request_phase_token; } public string get_access_phase_token() { assert(access_phase_token != null); return access_phase_token; } public string get_access_phase_token_secret() { assert(access_phase_token_secret != null); return access_phase_token_secret; } public string get_username() { assert(is_authenticated()); return username; } } internal const string API_KEY = "60dd96d4a2ad04888b09c9e18d82c26f"; internal const string API_SECRET = "d0960565e03547c1"; internal const string SERVICE_WELCOME_MESSAGE = _("You are not currently logged into Flickr.\n\nClick Log in to log into Flickr in your Web browser. You will have to authorize Shotwell Connect to link to your Flickr account."); internal class AuthenticationRequestTransaction : Transaction { public AuthenticationRequestTransaction(Session session) { base.with_uri(session, "https://www.flickr.com/services/oauth/request_token", Publishing.RESTSupport.HttpMethod.GET); } } internal class AccessTokenFetchTransaction : Transaction { public AccessTokenFetchTransaction(Session session, string user_verifier) { base.with_uri(session, "https://www.flickr.com/services/oauth/access_token", Publishing.RESTSupport.HttpMethod.GET); add_argument("oauth_verifier", user_verifier); add_argument("oauth_token", session.get_request_phase_token()); } } internal class PinEntryPane : Spit.Publishing.DialogPane, GLib.Object { private Gtk.Box pane_widget = null; private Gtk.Button continue_button = null; private Gtk.Entry pin_entry = null; private Gtk.Label pin_entry_caption = null; private Gtk.Label explanatory_text = null; private Gtk.Builder builder = null; public signal void proceed(PinEntryPane sender, string authorization_pin); public PinEntryPane(Gtk.Builder builder) { this.builder = builder; assert(builder != null); assert(builder.get_objects().length() > 0); explanatory_text = builder.get_object("explanatory_text") as Gtk.Label; pin_entry_caption = builder.get_object("pin_entry_caption") as Gtk.Label; pin_entry = builder.get_object("pin_entry") as Gtk.Entry; continue_button = builder.get_object("continue_button") as Gtk.Button; pane_widget = builder.get_object("pane_widget") as Gtk.Box; pane_widget.show_all(); on_pin_entry_contents_changed(); } private void on_continue_clicked() { proceed(this, pin_entry.get_text()); } private void on_pin_entry_contents_changed() { continue_button.set_sensitive(pin_entry.text_length > 0); } public Gtk.Widget get_widget() { return pane_widget; } public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { return Spit.Publishing.DialogPane.GeometryOptions.NONE; } public void on_pane_installed() { continue_button.clicked.connect(on_continue_clicked); pin_entry.changed.connect(on_pin_entry_contents_changed); } public void on_pane_uninstalled() { continue_button.clicked.disconnect(on_continue_clicked); pin_entry.changed.disconnect(on_pin_entry_contents_changed); } } internal class Flickr : GLib.Object, Spit.Publishing.Authenticator { private GLib.HashTable params; private Session session; private Spit.Publishing.PluginHost host; public Flickr(Spit.Publishing.PluginHost host) { base(); this.host = host; params = new GLib.HashTable(str_hash, str_equal); params.insert("ConsumerKey", API_KEY); params.insert("ConsumerSecret", API_SECRET); session = new Session(); session.set_api_credentials(API_KEY, API_SECRET); session.authenticated.connect(on_session_authenticated); } ~Flickr() { session.authenticated.disconnect(on_session_authenticated); } public void invalidate_persistent_session() { set_persistent_access_phase_token(""); set_persistent_access_phase_token_secret(""); set_persistent_access_phase_username(""); } private bool is_persistent_session_valid() { return (get_persistent_access_phase_username() != null && get_persistent_access_phase_token() != null && get_persistent_access_phase_token_secret() != null); } private string? get_persistent_access_phase_username() { return host.get_config_string("access_phase_username", null); } private void set_persistent_access_phase_username(string username) { host.set_config_string("access_phase_username", username); } private string? get_persistent_access_phase_token() { return host.get_config_string("access_phase_token", null); } private void set_persistent_access_phase_token(string token) { host.set_config_string("access_phase_token", token); } private string? get_persistent_access_phase_token_secret() { return host.get_config_string("access_phase_token_secret", null); } private void set_persistent_access_phase_token_secret(string secret) { host.set_config_string("access_phase_token_secret", secret); } public void authenticate() { if (is_persistent_session_valid()) { debug("attempt start: a persistent session is available; using it"); session.authenticate_from_persistent_credentials(get_persistent_access_phase_token(), get_persistent_access_phase_token_secret(), get_persistent_access_phase_username()); } else { debug("attempt start: no persistent session available; showing login welcome pane"); do_show_login_welcome_pane(); } } public bool can_logout() { return true; } public GLib.HashTable get_authentication_parameter() { return this.params; } public void logout () { session.deauthenticate(); invalidate_persistent_session(); } public void refresh() { // No-Op with flickr } private void do_show_login_welcome_pane() { debug("ACTION: installing login welcome pane"); host.set_service_locked(false); host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_welcome_pane_login_clicked); } private void on_welcome_pane_login_clicked() { debug("EVENT: user clicked 'Login' button in the welcome pane"); do_run_authentication_request_transaction(); } private void do_run_authentication_request_transaction() { debug("ACTION: running authentication request transaction"); host.set_service_locked(true); host.install_static_message_pane(_("Preparing for login…")); AuthenticationRequestTransaction txn = new AuthenticationRequestTransaction(session); txn.completed.connect(on_auth_request_txn_completed); txn.network_error.connect(on_auth_request_txn_error); try { txn.execute(); } catch (Spit.Publishing.PublishingError err) { host.post_error(err); } } private void on_auth_request_txn_completed(Publishing.RESTSupport.Transaction txn) { txn.completed.disconnect(on_auth_request_txn_completed); txn.network_error.disconnect(on_auth_request_txn_error); debug("EVENT: OAuth authentication request transaction completed; response = '%s'", txn.get_response()); do_parse_token_info_from_auth_request(txn.get_response()); } private void on_auth_request_txn_error(Publishing.RESTSupport.Transaction txn, Spit.Publishing.PublishingError err) { txn.completed.disconnect(on_auth_request_txn_completed); txn.network_error.disconnect(on_auth_request_txn_error); debug("EVENT: OAuth authentication request transaction caused a network error"); host.post_error(err); this.authentication_failed(); } private void do_parse_token_info_from_auth_request(string response) { debug("ACTION: parsing authorization request response '%s' into token and secret", response); string? oauth_token = null; string? oauth_token_secret = null; var data = Soup.Form.decode(response); data.lookup_extended("oauth_token", null, out oauth_token); data.lookup_extended("oauth_token_secret", null, out oauth_token_secret); if (oauth_token == null || oauth_token_secret == null) host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( "'%s' isn't a valid response to an OAuth authentication request", response)); on_authentication_token_available(oauth_token, oauth_token_secret); } private void on_authentication_token_available(string token, string token_secret) { debug("EVENT: OAuth authentication token (%s) and token secret (%s) available", token, token_secret); session.set_request_phase_credentials(token, token_secret); do_launch_system_browser(token); } private void on_system_browser_launched() { debug("EVENT: system browser launched."); do_show_pin_entry_pane(); } private void on_pin_entry_proceed(PinEntryPane sender, string pin) { sender.proceed.disconnect(on_pin_entry_proceed); debug("EVENT: user clicked 'Continue' in PIN entry pane."); do_verify_pin(pin); } private void do_launch_system_browser(string token) { string login_uri = "https://www.flickr.com/services/oauth/authorize?oauth_token=" + token + "&perms=write"; debug("ACTION: launching system browser with uri = '%s'", login_uri); try { Process.spawn_command_line_async("xdg-open " + login_uri); } catch (SpawnError e) { host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( "couldn't launch system web browser to complete Flickr login")); return; } on_system_browser_launched(); } private void do_show_pin_entry_pane() { debug("ACTION: showing PIN entry pane"); Gtk.Builder builder = new Gtk.Builder(); try { builder.add_from_resource (Resources.RESOURCE_PATH + "/" + "flickr_pin_entry_pane.ui"); } catch (Error e) { warning("Could not parse UI file! Error: %s.", e.message); host.post_error( new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( _("A file required for publishing is unavailable. Publishing to Flickr can’t continue."))); return; } PinEntryPane pin_entry_pane = new PinEntryPane(builder); pin_entry_pane.proceed.connect(on_pin_entry_proceed); host.install_dialog_pane(pin_entry_pane); } private void do_verify_pin(string pin) { debug("ACTION: validating authorization PIN %s", pin); host.set_service_locked(true); host.install_static_message_pane(_("Verifying authorization…")); AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session, pin); txn.completed.connect(on_access_token_fetch_txn_completed); txn.network_error.connect(on_access_token_fetch_error); try { txn.execute(); } catch (Spit.Publishing.PublishingError err) { host.post_error(err); } } private void on_access_token_fetch_txn_completed(Publishing.RESTSupport.Transaction txn) { txn.completed.disconnect(on_access_token_fetch_txn_completed); txn.network_error.disconnect(on_access_token_fetch_error); debug("EVENT: fetching OAuth access token over the network succeeded"); do_extract_access_phase_credentials_from_reponse(txn.get_response()); } private void on_access_token_fetch_error(Publishing.RESTSupport.Transaction txn, Spit.Publishing.PublishingError err) { txn.completed.disconnect(on_access_token_fetch_txn_completed); txn.network_error.disconnect(on_access_token_fetch_error); debug("EVENT: fetching OAuth access token over the network caused an error."); host.post_error(err); this.authentication_failed(); } private void do_extract_access_phase_credentials_from_reponse(string response) { debug("ACTION: extracting access phase credentials from '%s'", response); string? token = null; string? token_secret = null; string? username = null; var data = Soup.Form.decode(response); data.lookup_extended("oauth_token", null, out token); data.lookup_extended("oauth_token_secret", null, out token_secret); data.lookup_extended("username", null, out username); debug("access phase credentials: { token = '%s'; token_secret = '%s'; username = '%s' }", token, token_secret, username); if (token == null || token_secret == null || username == null) { host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("expected " + "access phase credentials to contain token, token secret, and username but at " + "least one of these is absent")); this.authentication_failed(); } else { session.set_access_phase_credentials(token, token_secret, username); } } private void on_session_authenticated() { params.insert("AuthToken", session.get_access_phase_token()); params.insert("AuthTokenSecret", session.get_access_phase_token_secret()); params.insert("Username", session.get_username()); set_persistent_access_phase_token(session.get_access_phase_token()); set_persistent_access_phase_token_secret(session.get_access_phase_token_secret()); set_persistent_access_phase_username(session.get_username()); this.authenticated(); } } }