Implement proper two factor authentication

fixes #19, #23
pull/31/head
Ivan Habunek 2017-08-26 14:39:53 +02:00
rodzic cebc88d329
commit 62c4075fe1
5 zmienionych plików z 131 dodań i 45 usunięć

Wyświetl plik

@ -4,6 +4,7 @@ Changelog
**0.13.0 (TBA)** **0.13.0 (TBA)**
* Allow passing `--instance` and `--email` to login command * 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)** **0.12.0 (2016-05-08)**

Wyświetl plik

@ -37,29 +37,30 @@ Running ``toot <command> -h`` shows the documentation for the given command.
toot - a Mastodon CLI client toot - a Mastodon CLI client
Authentication: Authentication:
toot login Log into a Mastodon instance toot login Log into a Mastodon instance, does NOT support two factor authentication
toot login_2fa Log in using two factor authentication (experimental) toot login_browser Log in using your browser, supports regular and two factor authentication
toot logout Log out, delete stored access keys toot login_2fa Log in using two factor authentication in the console (hacky, experimental)
toot auth Show stored credentials toot logout Log out, delete stored access keys
toot auth Show stored credentials
Read: Read:
toot whoami Display logged in user details toot whoami Display logged in user details
toot whois Display account details toot whois Display account details
toot search Search for users or hashtags toot search Search for users or hashtags
toot timeline Show recent items in your public timeline toot timeline Show recent items in your public timeline
toot curses An experimental timeline app. toot curses An experimental timeline app.
Post: Post:
toot post Post a status text to your timeline toot post Post a status text to your timeline
toot upload Upload an image or video file toot upload Upload an image or video file
Accounts: Accounts:
toot follow Follow an account toot follow Follow an account
toot unfollow Unfollow an account toot unfollow Unfollow an account
toot mute Mute an account toot mute Mute an account
toot unmute Unmute an account toot unmute Unmute an account
toot block Block an account toot block Block an account
toot unblock Unblock an account toot unblock Unblock an account
To get help for each command run: To get help for each command run:
toot <command> --help toot <command> --help
@ -77,19 +78,23 @@ It is possible to pipe status text into `toot post`, for example:
Authentication 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:: .. code-block::
toot login 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:: .. 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 .. _instance: https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md

Wyświetl plik

@ -4,7 +4,7 @@ import logging
import re import re
import requests import requests
from future.moves.urllib.parse import urlparse from future.moves.urllib.parse import urlparse, urlencode
from requests import Request, Session from requests import Request, Session
from toot import CLIENT_NAME, CLIENT_WEBSITE from toot import CLIENT_NAME, CLIENT_WEBSITE
@ -53,10 +53,16 @@ def _process_response(response):
_log_response(response) _log_response(response)
if not response.ok: if not response.ok:
error = "Unknown error"
try: 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: except:
error = "Unknown error" pass
if response.status_code == 404: if response.status_code == 404:
raise NotFoundError(error) raise NotFoundError(error)
@ -131,6 +137,30 @@ def login(app, username, password):
return _process_response(response).json() 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): def post_status(app, user, status, visibility='public', media_ids=None):
return _post(app, user, '/api/v1/statuses', { return _post(app, user, '/api/v1/statuses', {
'status': status, 'status': status,

Wyświetl plik

@ -4,6 +4,7 @@ from __future__ import print_function
import json import json
import requests import requests
import webbrowser
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from builtins import input from builtins import input
@ -45,6 +46,15 @@ def create_app_interactive(instance=None):
return config.load_app(instance) or register_app(instance) 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): def login_interactive(app, email=None):
print_out("Log in to <green>{}</green>".format(app.instance)) print_out("Log in to <green>{}</green>".format(app.instance))
@ -62,12 +72,7 @@ def login_interactive(app, email=None):
except api.ApiError: except api.ApiError:
raise ConsoleError("Login failed") raise ConsoleError("Login failed")
user = User(app.instance, email, response['access_token']) return create_user(app, email, response['access_token'])
path = config.save_user(user)
print_out("Access token saved to: <green>{}</green>".format(path))
return user
def two_factor_login_interactive(app): def two_factor_login_interactive(app):
@ -118,9 +123,7 @@ def two_factor_login_interactive(app):
data = json.loads(initial_state.get_text()) data = json.loads(initial_state.get_text())
access_token = data['meta']['access_token'] access_token = data['meta']['access_token']
user = User(app.instance, email, access_token) return create_user(app, email, access_token)
path = config.save_user(user)
print_out("Access token saved to: <green>{}</green>".format(path))
def _print_timeline(item): def _print_timeline(item):
@ -222,6 +225,46 @@ def login_2fa(app, user, args):
print_out("<green>✓ Successfully logged in.</green>") 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): def logout(app, user, args):
config.delete_user() config.delete_user()

Wyświetl plik

@ -38,26 +38,33 @@ account_arg = (["account"], {
"help": "account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'", "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 = [ AUTH_COMMANDS = [
Command( Command(
name="login", name="login",
description="Log into a Mastodon instance", description="Log into a Mastodon instance, does NOT support two factor authentication",
arguments=[ arguments=[instance_arg, email_arg],
(["-i", "--instance"], { require_auth=False,
"type": str, ),
"help": 'mastodon instance to log into e.g. "mastodon.social"', Command(
}), name="login_browser",
(["-e", "--email"], { description="Log in using your browser, supports regular and two factor authentication",
"type": str, arguments=[instance_arg, email_arg],
"help": 'email address to log in with',
}),
],
require_auth=False, require_auth=False,
), ),
Command( Command(
name="login_2fa", name="login_2fa",
description="Log in using two factor authentication (experimental)", description="Log in using two factor authentication in the console (hacky, experimental)",
arguments=[], arguments=[],
require_auth=False, require_auth=False,
), ),