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': |