Implement proper two factor authentication - toot - Unnamed repository; edit this file 'description' to name the repository.
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) LICENSE
---
(DIR) commit 62c4075fe1a8aee25c8b4a06f1521461f84e1596
(DIR) parent cebc88d3292cbd46d486f97d1114cff6ce879335
(HTM) Author: Ivan Habunek <ivan@habunek.com>
Date: Sat, 26 Aug 2017 14:39:53 +0200
Implement proper two factor authentication
fixes #19, #23
Diffstat:
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(-)
---
(DIR) diff --git 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)**
(DIR) diff --git a/README.rst b/README.rst
@@ -37,29 +37,30 @@ Running ``toot <command> -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 <command> --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
(DIR) diff --git 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,
(DIR) diff --git 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: <green>{}</green>".format(path))
+
+ return user
+
+
def login_interactive(app, email=None):
print_out("Log in to <green>{}</green>".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: <green>{}</green>".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: <green>{}</green>".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("<green>✓ Successfully logged in.</green>")
+BROWSER_LOGIN_EXPLANATION = """
+This authentication method requires you to log into your Mastodon instance
+in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
+your account. When you do, you will be given an <yellow>authorization code</yellow>
+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("<green>✓ Successfully logged in.</green>")
+
+
def logout(app, user, args):
config.delete_user()
(DIR) diff --git 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,
),