diff --git a/README.rst b/README.rst index e5b81a6..7882a26 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,8 @@ Running ``toot -h`` shows the documentation for the given command. =================== =============================================================== Command Description =================== =============================================================== - ``toot login`` Log into a Mastodon instance, saves access keys for later use. + ``toot login`` Log into a Mastodon instance. + ``toot 2fa`` Log into a Mastodon instance using two factor authentication. ``toot logout`` Log out, deletes stored access keys. ``toot auth`` Display stored authenitication tokens. ``toot whoami`` Display logged in user details. @@ -53,13 +54,19 @@ Before tooting, you need to login to a Mastodon instance: toot login +**Two factor authentication** is supported experimentally, instead of ``login``, you should instead run: + +.. code-block:: + + toot 2fa + You will be asked to chose an instance_ and enter your credentials. .. _instance: https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md The application and user access tokens will be saved in two files in your home directory: -* ``~/.config/toot/app.cfg`` +* ``~/.config/toot/instances/`` - created for each mastodon instance once * ``~/.config/toot/user.cfg`` You can check whether you are currently logged in: diff --git a/toot/api.py b/toot/api.py index 05f6f19..c614c80 100644 --- a/toot/api.py +++ b/toot/api.py @@ -118,7 +118,7 @@ def login(app, username, password): # If auth fails, it redirects to the login page if response.is_redirect: - raise AuthenticationError("Login failed") + raise AuthenticationError() return _process_response(response) diff --git a/toot/console.py b/toot/console.py index 2bbc1ab..4cc491a 100644 --- a/toot/console.py +++ b/toot/console.py @@ -2,17 +2,19 @@ from __future__ import unicode_literals from __future__ import print_function -import os -import sys +import json import logging +import os +import requests +import sys +from argparse import ArgumentParser, FileType from bs4 import BeautifulSoup from builtins import input from datetime import datetime from future.moves.itertools import zip_longest from getpass import getpass from itertools import chain -from argparse import ArgumentParser, FileType from textwrap import TextWrapper from toot import api, config, DEFAULT_INSTANCE, User, App @@ -89,11 +91,65 @@ def login_interactive(app): return user +def two_factor_login_interactive(app): + """Hacky implementation of two factor authentication""" + + print("Log in to " + green(app.instance)) + email = input('Email: ') + password = getpass('Password: ') + + sign_in_url = app.base_url + '/auth/sign_in' + + session = requests.Session() + + # Fetch sign in form + response = session.get(sign_in_url) + response.raise_for_status() + + soup = BeautifulSoup(response.content, "html.parser") + form = soup.find('form') + inputs = form.find_all('input') + + data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs} + data['user[email]'] = email + data['user[password]'] = password + + # Submit form, get 2FA entry form + response = session.post(sign_in_url, data) + response.raise_for_status() + + soup = BeautifulSoup(response.content, "html.parser") + form = soup.find('form') + inputs = form.find_all('input') + + data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs} + data['user[otp_attempt]'] = input("2FA Token: ") + + # Submit token + response = session.post(sign_in_url, data) + response.raise_for_status() + + # Extract access token from response + soup = BeautifulSoup(response.content, "html.parser") + initial_state = soup.find('script', id='initial-state') + + if not initial_state: + raise ConsoleError("Login failed: Invalid 2FA token?") + + 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("Access token saved to: " + green(path)) + + def print_usage(): print("toot - interact with Mastodon from the command line") print("") print("Usage:") - print(" toot login - log into a Mastodon instance (stores access tokens)") + print(" toot login - log into a Mastodon instance") + print(" toot 2fa - log into a Mastodon instance using 2FA (experimental)") print(" toot logout - log out (delete stored access tokens)") print(" toot auth - display stored authentication tokens") print(" toot whoami - display logged in user details") @@ -221,6 +277,24 @@ def cmd_login(args): return app, user +def cmd_2fa(args): + parser = ArgumentParser(prog="toot 2fa", + description="Log into a Mastodon instance using 2 factor authentication (experimental)", + epilog="https://github.com/ihabunek/toot") + parser.parse_args(args) + + print() + print(yellow("Two factor authentication is experimental.")) + print(yellow("If you have problems logging in, please open an issue:")) + print(yellow("https://github.com/ihabunek/toot/issues")) + print() + + app = create_app_interactive() + user = two_factor_login_interactive(app) + + return app, user + + def cmd_logout(app, user, args): parser = ArgumentParser(prog="toot logout", description="Log out, delete stored access keys", @@ -363,6 +437,9 @@ def run_command(command, args): if command == 'login': return cmd_login(args) + if command == '2fa': + return cmd_2fa(args) + if command == 'auth': return cmd_auth(app, user, args)