From 62c4075fe1a8aee25c8b4a06f1521461f84e1596 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 26 Aug 2017 14:39:53 +0200 Subject: [PATCH] Implement proper two factor authentication fixes #19, #23 --- CHANGELOG.md | 1 + README.rst | 47 ++++++++++++++++++++----------------- toot/api.py | 36 +++++++++++++++++++++++++--- toot/commands.py | 61 +++++++++++++++++++++++++++++++++++++++++------- toot/console.py | 31 ++++++++++++++---------- 5 files changed, 131 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 815e680..b0aaac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Changelog **0.13.0 (TBA)** * Allow passing `--instance` and `--email` to login command +* Add `login_browser` command for proper two factor authentication through the browser (#19, #23) **0.12.0 (2016-05-08)** diff --git a/README.rst b/README.rst index 88f9589..1dd1237 100644 --- a/README.rst +++ b/README.rst @@ -37,29 +37,30 @@ Running ``toot -h`` shows the documentation for the given command. toot - a Mastodon CLI client Authentication: - toot login Log into a Mastodon instance - toot login_2fa Log in using two factor authentication (experimental) - toot logout Log out, delete stored access keys - toot auth Show stored credentials + toot login Log into a Mastodon instance, does NOT support two factor authentication + toot login_browser Log in using your browser, supports regular and two factor authentication + toot login_2fa Log in using two factor authentication in the console (hacky, experimental) + toot logout Log out, delete stored access keys + toot auth Show stored credentials Read: - toot whoami Display logged in user details - toot whois Display account details - toot search Search for users or hashtags - toot timeline Show recent items in your public timeline - toot curses An experimental timeline app. + toot whoami Display logged in user details + toot whois Display account details + toot search Search for users or hashtags + toot timeline Show recent items in your public timeline + toot curses An experimental timeline app. Post: - toot post Post a status text to your timeline - toot upload Upload an image or video file + toot post Post a status text to your timeline + toot upload Upload an image or video file Accounts: - toot follow Follow an account - toot unfollow Unfollow an account - toot mute Mute an account - toot unmute Unmute an account - toot block Block an account - toot unblock Unblock an account + toot follow Follow an account + toot unfollow Unfollow an account + toot mute Mute an account + toot unmute Unmute an account + toot block Block an account + toot unblock Unblock an account To get help for each command run: toot --help @@ -77,19 +78,23 @@ It is possible to pipe status text into `toot post`, for example: Authentication -------------- -Before tooting, you need to login to a Mastodon instance: +Before tooting, you need to login to a Mastodon instance. + +If you don't use two factor authentication you can log in directly from the command line: .. code-block:: toot login -**Two factor authentication** is supported experimentally, instead of ``login``, you should instead run ``login_2fa``: +You will be asked to chose an instance_ and enter your credentials. + +If you do use **two factor authentication**, you need to log in through your browser: .. code-block:: - toot login_2fa + toot login_browser -You will be asked to chose an instance_ and enter your credentials. +You will be redirected to your Mastodon instance to log in and authorize toot to access your account, and will be given an **authorization code** in return which you need to enter to log in. .. _instance: https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md diff --git a/toot/api.py b/toot/api.py index f5be0a5..2dff648 100644 --- a/toot/api.py +++ b/toot/api.py @@ -4,7 +4,7 @@ import logging import re import requests -from future.moves.urllib.parse import urlparse +from future.moves.urllib.parse import urlparse, urlencode from requests import Request, Session from toot import CLIENT_NAME, CLIENT_WEBSITE @@ -53,10 +53,16 @@ def _process_response(response): _log_response(response) if not response.ok: + error = "Unknown error" + try: - error = response.json()['error'] + data = response.json() + if "error_description" in data: + error = data['error_description'] + elif "error" in data: + error = data['error'] except: - error = "Unknown error" + pass if response.status_code == 404: raise NotFoundError(error) @@ -131,6 +137,30 @@ def login(app, username, password): return _process_response(response).json() +def get_browser_login_url(app): + """Returns the URL for manual log in via browser""" + return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({ + "response_type": "code", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "scope": "read write follow", + "client_id": app.client_id, + })) + + +def request_access_token(app, authorization_code): + url = app.base_url + '/oauth/token' + + response = requests.post(url, { + 'grant_type': 'authorization_code', + 'client_id': app.client_id, + 'client_secret': app.client_secret, + 'code': authorization_code, + 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', + }, allow_redirects=False) + + return _process_response(response).json() + + def post_status(app, user, status, visibility='public', media_ids=None): return _post(app, user, '/api/v1/statuses', { 'status': status, diff --git a/toot/commands.py b/toot/commands.py index 9f953eb..7daa754 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -4,6 +4,7 @@ from __future__ import print_function import json import requests +import webbrowser from bs4 import BeautifulSoup from builtins import input @@ -45,6 +46,15 @@ def create_app_interactive(instance=None): return config.load_app(instance) or register_app(instance) +def create_user(app, email, access_token): + user = User(app.instance, email, access_token) + path = config.save_user(user) + + print_out("Access token saved to: {}".format(path)) + + return user + + def login_interactive(app, email=None): print_out("Log in to {}".format(app.instance)) @@ -62,12 +72,7 @@ def login_interactive(app, email=None): except api.ApiError: raise ConsoleError("Login failed") - user = User(app.instance, email, response['access_token']) - path = config.save_user(user) - - print_out("Access token saved to: {}".format(path)) - - return user + return create_user(app, email, response['access_token']) def two_factor_login_interactive(app): @@ -118,9 +123,7 @@ def two_factor_login_interactive(app): data = json.loads(initial_state.get_text()) access_token = data['meta']['access_token'] - user = User(app.instance, email, access_token) - path = config.save_user(user) - print_out("Access token saved to: {}".format(path)) + return create_user(app, email, access_token) def _print_timeline(item): @@ -222,6 +225,46 @@ def login_2fa(app, user, args): print_out("✓ Successfully logged in.") +BROWSER_LOGIN_EXPLANATION = """ +This authentication method requires you to log into your Mastodon instance +in your browser, where you will be asked to authorize toot to access +your account. When you do, you will be given an authorization code +which you need to paste here. +""" + + +def login_browser(app, user, args): + app = create_app_interactive(instance=args.instance) + url = api.get_browser_login_url(app) + + print_out(BROWSER_LOGIN_EXPLANATION) + + print_out("This is the login URL:") + print_out(url) + print_out("") + + yesno = input("Open link in default browser? [Y/n]") + if not yesno or yesno.lower() == 'y': + webbrowser.open(url) + + authorization_code = "" + while not authorization_code: + authorization_code = input("Authorization code: ") + + print_out("\nRequesting access token...") + response = api.request_access_token(app, authorization_code) + + # TODO: user email is not available in this workflow, maybe change the User + # to store the username instead? Currently set to "unknown" since it's not + # used anywhere. + email = "unknown" + + create_user(app, email, response['access_token']) + + print_out() + print_out("✓ Successfully logged in.") + + def logout(app, user, args): config.delete_user() diff --git a/toot/console.py b/toot/console.py index 6034ca7..2dc794b 100644 --- a/toot/console.py +++ b/toot/console.py @@ -38,26 +38,33 @@ account_arg = (["account"], { "help": "account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'", }) +instance_arg = (["-i", "--instance"], { + "type": str, + "help": 'mastodon instance to log into e.g. "mastodon.social"', +}) + +email_arg = (["-e", "--email"], { + "type": str, + "help": 'email address to log in with', +}) + AUTH_COMMANDS = [ Command( name="login", - description="Log into a Mastodon instance", - arguments=[ - (["-i", "--instance"], { - "type": str, - "help": 'mastodon instance to log into e.g. "mastodon.social"', - }), - (["-e", "--email"], { - "type": str, - "help": 'email address to log in with', - }), - ], + description="Log into a Mastodon instance, does NOT support two factor authentication", + arguments=[instance_arg, email_arg], + require_auth=False, + ), + Command( + name="login_browser", + description="Log in using your browser, supports regular and two factor authentication", + arguments=[instance_arg, email_arg], require_auth=False, ), Command( name="login_2fa", - description="Log in using two factor authentication (experimental)", + description="Log in using two factor authentication in the console (hacky, experimental)", arguments=[], require_auth=False, ),