Introduction
Introduction Statistics Contact Development Disclaimer Help
Store access tokens for multiple instances - toot - Unnamed repository; edit th…
Log
Files
Refs
LICENSE
---
commit 3f44d560c8c1159df2cf318cc71a9d235fde6f55
parent ed20c7fded6ba5dfb7124bf223d9bf9b3e213ab3
Author: Ivan Habunek <[email protected]>
Date: Tue, 18 Apr 2017 16:16:24 +0200
Store access tokens for multiple instances
This makes it so an app is created only once for each instance, instead
of being re-created on each login. Prevents accumulations of authroized
apps in https://mastodon.social/oauth/authorized_applications
Diffstat:
tests/test_api.py | 77 ++++++++++++++++++-------------
tests/test_console.py | 6 +++---
tests/utils.py | 5 +++--
toot/__init__.py | 4 ++--
toot/api.py | 28 ++++++++++++----------------
toot/config.py | 45 +++++++++++++++++++++++--------
toot/console.py | 64 +++++++++++++++++--------------
7 files changed, 136 insertions(+), 93 deletions(-)
---
diff --git a/tests/test_api.py b/tests/test_api.py
@@ -1,22 +1,18 @@
# -*- coding: utf-8 -*-
+import pytest
import requests
-from toot import App, User, CLIENT_NAME, CLIENT_WEBSITE
-from toot.api import create_app, login, SCOPES
-
-
-class MockResponse:
- def __init__(self, response_data={}):
- self.response_data = response_data
-
- def raise_for_status(self):
- pass
-
- def json(self):
- return self.response_data
+from toot import App, CLIENT_NAME, CLIENT_WEBSITE
+from toot.api import create_app, login, SCOPES, AuthenticationError
+from tests.utils import MockResponse
def test_create_app(monkeypatch):
+ response = {
+ 'client_id': 'foo',
+ 'client_secret': 'bar',
+ }
+
def mock_post(url, data):
assert url == 'https://bigfish.software/api/v1/apps'
assert data == {
@@ -25,24 +21,25 @@ def test_create_app(monkeypatch):
'scopes': SCOPES,
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob'
}
- return MockResponse({
- 'client_id': 'foo',
- 'client_secret': 'bar',
- })
+ return MockResponse(response)
monkeypatch.setattr(requests, 'post', mock_post)
- app = create_app('https://bigfish.software')
-
- assert isinstance(app, App)
- assert app.client_id == 'foo'
- assert app.client_secret == 'bar'
+ assert create_app('bigfish.software') == response
def test_login(monkeypatch):
- app = App('https://bigfish.software', 'foo', 'bar')
+ app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
- def mock_post(url, data):
+ response = {
+ 'token_type': 'bearer',
+ 'scope': 'read write follow',
+ 'access_token': 'xxx',
+ 'created_at': 1492523699
+ }
+
+ def mock_post(url, data, allow_redirects):
+ assert not allow_redirects
assert url == 'https://bigfish.software/oauth/token'
assert data == {
'grant_type': 'password',
@@ -52,14 +49,32 @@ def test_login(monkeypatch):
'password': 'pass',
'scope': SCOPES,
}
- return MockResponse({
- 'access_token': 'xxx',
- })
+
+ return MockResponse(response)
monkeypatch.setattr(requests, 'post', mock_post)
- user = login(app, 'user', 'pass')
+ assert login(app, 'user', 'pass') == response
+
+
+def test_login_failed(monkeypatch):
+ app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
+
+ def mock_post(url, data, allow_redirects):
+ assert not allow_redirects
+ assert url == 'https://bigfish.software/oauth/token'
+ assert data == {
+ 'grant_type': 'password',
+ 'client_id': app.client_id,
+ 'client_secret': app.client_secret,
+ 'username': 'user',
+ 'password': 'pass',
+ 'scope': SCOPES,
+ }
+
+ return MockResponse(is_redirect=True)
+
+ monkeypatch.setattr(requests, 'post', mock_post)
- assert isinstance(user, User)
- assert user.username == 'user'
- assert user.access_token == 'xxx'
+ with pytest.raises(AuthenticationError):
+ login(app, 'user', 'pass')
diff --git a/tests/test_console.py b/tests/test_console.py
@@ -7,8 +7,8 @@ from toot import console, User, App
from tests.utils import MockResponse
-app = App('https://habunek.com', 'foo', 'bar')
-user = User('[email protected]', 'xxx')
+app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
+user = User('habunek.com', '[email protected]', 'xxx')
def uncolorize(text):
@@ -16,7 +16,7 @@ def uncolorize(text):
return re.sub(r'\x1b[^m]*m', '', text)
-def test_print_usagecap(capsys):
+def test_print_usage(capsys):
console.print_usage()
out, err = capsys.readouterr()
assert "toot - interact with Mastodon from the command line" in out
diff --git a/tests/utils.py b/tests/utils.py
@@ -1,8 +1,9 @@
class MockResponse:
- def __init__(self, response_data={}, ok=True):
- self.ok = ok
+ def __init__(self, response_data={}, ok=True, is_redirect=False):
self.response_data = response_data
+ self.ok = ok
+ self.is_redirect = is_redirect
def raise_for_status(self):
pass
diff --git a/toot/__init__.py b/toot/__init__.py
@@ -2,8 +2,8 @@
from collections import namedtuple
-App = namedtuple('App', ['base_url', 'client_id', 'client_secret'])
-User = namedtuple('User', ['username', 'access_token'])
+App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
+User = namedtuple('User', ['instance', 'username', 'access_token'])
DEFAULT_INSTANCE = 'mastodon.social'
diff --git a/toot/api.py b/toot/api.py
@@ -20,6 +20,10 @@ class NotFoundError(ApiError):
pass
+class AuthenticationError(ApiError):
+ pass
+
+
def _log_request(request):
logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url…
logger.debug(">>> HEADERS: \033[33m{}\033[0m".format(request.headers))
@@ -57,8 +61,6 @@ def _process_response(response):
raise ApiError(error)
- response.raise_for_status()
-
return response.json()
@@ -88,7 +90,8 @@ def _post(app, user, url, data=None, files=None):
return _process_response(response)
-def create_app(base_url):
+def create_app(instance):
+ base_url = 'https://' + instance
url = base_url + '/api/v1/apps'
response = requests.post(url, {
@@ -98,13 +101,7 @@ def create_app(base_url):
'website': CLIENT_WEBSITE,
})
- response.raise_for_status()
-
- data = response.json()
- client_id = data.get('client_id')
- client_secret = data.get('client_secret')
-
- return App(base_url, client_id, client_secret)
+ return _process_response(response)
def login(app, username, password):
@@ -117,14 +114,13 @@ def login(app, username, password):
'username': username,
'password': password,
'scope': SCOPES,
- })
+ }, allow_redirects=False)
- response.raise_for_status()
+ # If auth fails, it redirects to the login page
+ if response.is_redirect:
+ raise AuthenticationError("Login failed")
- data = response.json()
- access_token = data.get('access_token')
-
- return User(username, access_token)
+ return _process_response(response)
def post_status(app, user, status, visibility='public', media_ids=None):
diff --git a/toot/config.py b/toot/config.py
@@ -4,11 +4,24 @@ import os
from . import User, App
+# The dir where all toot configuration is stored
CONFIG_DIR = os.environ['HOME'] + '/.config/toot/'
-CONFIG_APP_FILE = CONFIG_DIR + 'app.cfg'
+
+# Subfolder where application access keys for various instances are stored
+INSTANCES_DIR = CONFIG_DIR + 'instances/'
+
+# File in which user access token is stored
CONFIG_USER_FILE = CONFIG_DIR + 'user.cfg'
+def get_instance_config_path(instance):
+ return INSTANCES_DIR + instance
+
+
+def get_user_config_path():
+ return CONFIG_USER_FILE
+
+
def _load(file, tuple_class):
if not os.path.exists(file):
return None
@@ -28,28 +41,38 @@ def _save(file, named_tuple):
with open(file, 'w') as f:
values = [v for v in named_tuple]
- return f.write("\n".join(values))
+ f.write("\n".join(values))
-def load_app():
- return _load(CONFIG_APP_FILE, App)
+def load_app(instance):
+ path = get_instance_config_path(instance)
+ return _load(path, App)
def load_user():
- return _load(CONFIG_USER_FILE, User)
+ path = get_user_config_path()
+ return _load(path, User)
def save_app(app):
- return _save(CONFIG_APP_FILE, app)
+ path = get_instance_config_path(app.instance)
+ _save(path, app)
+ return path
def save_user(user):
- return _save(CONFIG_USER_FILE, user)
+ path = get_user_config_path()
+ _save(path, user)
+ return path
-def delete_app(app):
- return os.unlink(CONFIG_APP_FILE)
+def delete_app(instance):
+ path = get_instance_config_path(instance)
+ os.unlink(path)
+ return path
-def delete_user(user):
- return os.unlink(CONFIG_USER_FILE)
+def delete_user():
+ path = get_user_config_path()
+ os.unlink(path)
+ return path
diff --git a/toot/console.py b/toot/console.py
@@ -15,9 +15,8 @@ from itertools import chain
from argparse import ArgumentParser, FileType
from textwrap import TextWrapper
-from toot import api, DEFAULT_INSTANCE
+from toot import api, config, DEFAULT_INSTANCE, User, App
from toot.api import ApiError
-from toot.config import save_user, load_user, load_app, save_app, CONFIG_APP_F…
class ConsoleError(Exception):
@@ -44,38 +43,48 @@ def print_error(text):
print(red(text), file=sys.stderr)
-def create_app_interactive():
- instance = input("Choose an instance [%s]: " % green(DEFAULT_INSTANCE))
- if not instance:
- instance = DEFAULT_INSTANCE
+def register_app(instance):
+ print("Registering application with %s" % green(instance))
- base_url = 'https://{}'.format(instance)
-
- print("Registering application with %s" % green(base_url))
try:
- app = api.create_app(base_url)
+ response = api.create_app(instance)
except:
- raise ConsoleError("Failed authenticating application. Did you enter a…
+ raise ConsoleError("Registration failed. Did you enter a valid instanc…
+
+ base_url = 'https://' + instance
- save_app(app)
- print("Application tokens saved to: {}".format(green(CONFIG_APP_FILE)))
+ app = App(instance, base_url, response['client_id'], response['client_secr…
+ path = config.save_app(app)
+ print("Application tokens saved to: {}".format(green(path)))
return app
+def create_app_interactive():
+ instance = input("Choose an instance [%s]: " % green(DEFAULT_INSTANCE))
+ if not instance:
+ instance = DEFAULT_INSTANCE
+
+ return config.load_app(instance) or register_app(instance)
+
+
def login_interactive(app):
- print("\nLog in to " + green(app.base_url))
+ print("\nLog in to " + green(app.instance))
email = input('Email: ')
password = getpass('Password: ')
- print("Authenticating...")
+ if not email or not password:
+ raise ConsoleError("Email and password cannot be empty.")
+
try:
- user = api.login(app, email, password)
- except:
+ print("Authenticating...")
+ response = api.login(app, email, password)
+ except ApiError:
raise ConsoleError("Login failed")
- save_user(user)
- print("User token saved to " + green(CONFIG_USER_FILE))
+ user = User(app.instance, email, response['access_token'])
+ path = config.save_user(user)
+ print("Access token saved to: " + green(path))
return user
@@ -193,10 +202,9 @@ def cmd_auth(app, user, args):
parser.parse_args(args)
if app and user:
- print("You are logged in to " + green(app.base_url))
- print("Username: " + green(user.username))
- print("App data: " + green(CONFIG_APP_FILE))
- print("User data: " + green(CONFIG_USER_FILE))
+ print("You are logged in to {} as {}".format(green(app.instance), gree…
+ print("User data: " + green(config.get_user_config_path()))
+ print("App data: " + green(config.get_instance_config_path(app.instan…
else:
print("You are not logged in")
@@ -219,9 +227,9 @@ def cmd_logout(app, user, args):
epilog="https://github.com/ihabunek/toot")
parser.parse_args(args)
- os.unlink(CONFIG_APP_FILE)
- os.unlink(CONFIG_USER_FILE)
- print("You are now logged out")
+ config.delete_user()
+
+ print(green("✓ You are now logged out"))
def cmd_upload(app, user, args):
@@ -348,8 +356,8 @@ def cmd_whoami(app, user, args):
def run_command(command, args):
- app = load_app()
- user = load_user()
+ user = config.load_user()
+ app = config.load_app(user.instance) if user else None
# Commands which can run when not logged in
if command == 'login':
You are viewing proxied material from vernunftzentrum.de. The copyright of proxied material belongs to its original authors. Any comments or complaints in relation to proxied material should be directed to the original authors of the content concerned. Please see the disclaimer for more details.