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,
            ),