Migrate auth commands

pull/444/head
Ivan Habunek 2023-11-30 20:12:04 +01:00
rodzic 696a9dcc2e
commit d8c7084678
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: CDBD63C43A30BB95
14 zmienionych plików z 459 dodań i 117 usunięć

Wyświetl plik

@ -15,7 +15,7 @@ test:
coverage: coverage:
coverage erase coverage erase
coverage run coverage run
coverage html coverage html --omit toot/tui/*
coverage report coverage report
clean : clean :

Wyświetl plik

@ -1,3 +1,9 @@
0.40.0:
date: TBA
changes:
- "Migrated to `click` for commandline arguments. BC should be mostly preserved, please report any issues."
- "Removed the deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
0.39.0: 0.39.0:
date: 2023-11-23 date: 2023-11-23
changes: changes:

Wyświetl plik

@ -0,0 +1,217 @@
from typing import Any, Dict
from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from toot.cli.base import Run
# TODO: figure out how to test login
EMPTY_CONFIG: Dict[Any, Any] = {
"apps": {},
"users": {},
"active_user": None
}
SAMPLE_CONFIG = {
"active_user": "frank@foo.social",
"apps": {
"foo.social": {
"base_url": "http://foo.social",
"client_id": "123",
"client_secret": "123",
"instance": "foo.social"
},
"bar.social": {
"base_url": "http://bar.social",
"client_id": "123",
"client_secret": "123",
"instance": "bar.social"
},
},
"users": {
"frank@foo.social": {
"access_token": "123",
"instance": "foo.social",
"username": "frank"
},
"frank@bar.social": {
"access_token": "123",
"instance": "bar.social",
"username": "frank"
},
}
}
def test_env(run: Run):
result = run(cli.env)
assert result.exit_code == 0
assert "toot" in result.stdout
assert "Python" in result.stdout
@mock.patch("toot.config.load_config")
def test_auth_empty(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth)
assert result.exit_code == 0
assert result.stdout.strip() == "You are not logged in to any accounts"
@mock.patch("toot.config.load_config")
def test_auth_full(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth)
assert result.exit_code == 0
assert result.stdout.strip().startswith("Authenticated accounts:")
assert "frank@foo.social" in result.stdout
assert "frank@bar.social" in result.stdout
# Saving config is mocked so we don't mess up our local config
# TODO: could this be implemented using an auto-use fixture so we have it always
# mocked?
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
result = run(
cli.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "password",
)
assert result.exit_code == 0
assert "✓ Successfully logged in." in result.stdout
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_called_once()
(new_user,) = save_user.call_args.args
assert new_user.instance == "localhost:3000"
assert new_user.username == user.username
# access token will be different since this is a new login
assert new_user.access_token and new_user.access_token != user.access_token
assert save_user.call_args.kwargs == {"activate": True}
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli_wrong_password(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
result = run(
cli.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "wrong password",
)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Login failed"
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_not_called()
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.delete_user")
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.logout, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
@mock.patch("toot.config.load_config")
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.logout)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
@mock.patch("toot.config.load_config")
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.logout)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to log out")
@mock.patch("toot.config.load_config")
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.logout, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.activate_user")
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.activate, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
@mock.patch("toot.config.load_config")
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.activate)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
@mock.patch("toot.config.load_config")
def test_activate_account_not_given(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.activate)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to activate")
@mock.patch("toot.config.load_config")
def test_activate_invalid_Account(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.activate, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")

Wyświetl plik

@ -1,7 +1,7 @@
from argparse import ArgumentTypeError import click
import pytest import pytest
from toot.console import duration from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.utils import urlencode_url from toot.utils import urlencode_url
@ -163,6 +163,9 @@ def test_wc_wrap_indented():
def test_duration(): def test_duration():
def duration(value):
return validate_duration(None, None, value)
# Long hand # Long hand
assert duration("1 second") == 1 assert duration("1 second") == 1
assert duration("1 seconds") == 1 assert duration("1 seconds") == 1
@ -190,17 +193,17 @@ def test_duration():
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1 assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1 assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
with pytest.raises(ArgumentTypeError): with pytest.raises(click.BadParameter):
duration("") duration("")
with pytest.raises(ArgumentTypeError): with pytest.raises(click.BadParameter):
duration("100") duration("100")
# Wrong order # Wrong order
with pytest.raises(ArgumentTypeError): with pytest.raises(click.BadParameter):
duration("1m1d") duration("1m1d")
with pytest.raises(ArgumentTypeError): with pytest.raises(click.BadParameter):
duration("banana") duration("banana")

Wyświetl plik

@ -140,7 +140,7 @@ def fetch_app_token(app):
return http.anon_post(f"{app.base_url}/oauth/token", json=json).json() return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
def login(app, username, password): def login(app: App, username: str, password: str):
url = app.base_url + '/oauth/token' url = app.base_url + '/oauth/token'
data = { data = {
@ -152,16 +152,10 @@ def login(app, username, password):
'scope': SCOPES, 'scope': SCOPES,
} }
response = http.anon_post(url, data=data, allow_redirects=False) return http.anon_post(url, data=data).json()
# If auth fails, it redirects to the login page
if response.is_redirect:
raise AuthenticationError()
return response.json()
def get_browser_login_url(app): def get_browser_login_url(app: App) -> str:
"""Returns the URL for manual log in via browser""" """Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({ return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"response_type": "code", "response_type": "code",
@ -171,7 +165,7 @@ def get_browser_login_url(app):
})) }))
def request_access_token(app, authorization_code): def request_access_token(app: App, authorization_code: str):
url = app.base_url + '/oauth/token' url = app.base_url + '/oauth/token'
data = { data = {

Wyświetl plik

@ -1,18 +1,19 @@
import sys from toot import api, config, User, App
import webbrowser from toot.entities import from_dict, Instance
from builtins import input
from getpass import getpass
from toot import api, config, DEFAULT_INSTANCE, User, App
from toot.exceptions import ApiError, ConsoleError from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
from urllib.parse import urlparse from urllib.parse import urlparse
def register_app(domain, base_url): def find_instance(base_url: str) -> Instance:
try:
instance = api.get_instance(base_url).json()
return from_dict(Instance, instance)
except Exception:
raise ConsoleError(f"Instance not found at {base_url}")
def register_app(domain: str, base_url: str) -> App:
try: try:
print_out("Registering application...")
response = api.create_app(base_url) response = api.create_app(base_url)
except ApiError: except ApiError:
raise ConsoleError("Registration failed.") raise ConsoleError("Registration failed.")
@ -20,109 +21,54 @@ def register_app(domain, base_url):
app = App(domain, base_url, response['client_id'], response['client_secret']) app = App(domain, base_url, response['client_id'], response['client_secret'])
config.save_app(app) config.save_app(app)
print_out("Application tokens saved.")
return app return app
def create_app_interactive(base_url): def get_or_create_app(base_url: str) -> App:
if not base_url: instance = find_instance(base_url)
print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="") domain = _get_instance_domain(instance)
base_url = input()
if not base_url:
base_url = DEFAULT_INSTANCE
domain = get_instance_domain(base_url)
return config.load_app(domain) or register_app(domain, base_url) return config.load_app(domain) or register_app(domain, base_url)
def get_instance_domain(base_url): def create_user(app: App, access_token: str) -> User:
print_out("Looking up instance info...")
instance = api.get_instance(base_url).json()
print_out(
f"Found instance <blue>{instance['title']}</blue> "
f"running Mastodon version <yellow>{instance['version']}</yellow>"
)
# Pleroma and its forks return an actual URI here, rather than a
# domain name like Mastodon. This is contrary to the spec.¯
# in that case, parse out the domain and return it.
uri = instance["uri"]
if uri.startswith("http"):
return urlparse(uri).netloc
return uri
# NB: when updating to v2 instance endpoint, this field has been renamed to `domain`
def create_user(app, access_token):
# Username is not yet known at this point, so fetch it from Mastodon # Username is not yet known at this point, so fetch it from Mastodon
user = User(app.instance, None, access_token) user = User(app.instance, None, access_token)
creds = api.verify_credentials(app, user).json() creds = api.verify_credentials(app, user).json()
user = User(app.instance, creds['username'], access_token) user = User(app.instance, creds["username"], access_token)
config.save_user(user, activate=True) config.save_user(user, activate=True)
print_out("Access token saved to config at: <green>{}</green>".format(
config.get_config_file_path()))
return user return user
def login_interactive(app, email=None): def login_username_password(app: App, email: str, password: str) -> User:
print_out("Log in to <green>{}</green>".format(app.instance))
if email:
print_out("Email: <green>{}</green>".format(email))
while not email:
email = input('Email: ')
# Accept password piped from stdin, useful for testing purposes but not
# documented so people won't get ideas. Otherwise prompt for password.
if sys.stdin.isatty():
password = getpass('Password: ')
else:
password = sys.stdin.read().strip()
print_out("Password: <green>read from stdin</green>")
try: try:
print_out("Authenticating...")
response = api.login(app, email, password) response = api.login(app, email, password)
except ApiError: except Exception:
raise ConsoleError("Login failed") raise ConsoleError("Login failed")
return create_user(app, response['access_token']) return create_user(app, response["access_token"])
BROWSER_LOGIN_EXPLANATION = """ def login_auth_code(app: App, authorization_code: str) -> User:
This authentication method requires you to log into your Mastodon instance try:
in your browser, where you will be asked to authorize <yellow>toot</yellow> to access response = api.request_access_token(app, authorization_code)
your account. When you do, you will be given an <yellow>authorization code</yellow> except Exception:
which you need to paste here. raise ConsoleError("Login failed")
"""
return create_user(app, response["access_token"])
def login_browser_interactive(app): def _get_instance_domain(instance: Instance) -> str:
url = api.get_browser_login_url(app) """Extracts the instance domain name.
print_out(BROWSER_LOGIN_EXPLANATION)
print_out("This is the login URL:") Pleroma and its forks return an actual URI here, rather than a domain name
print_out(url) like Mastodon. This is contrary to the spec.¯ in that case, parse out the
print_out("") domain and return it.
yesno = input("Open link in default browser? [Y/n]") TODO: when updating to v2 instance endpoint, this field has been renamed to
if not yesno or yesno.lower() == 'y': `domain`
webbrowser.open(url) """
if instance.uri.startswith("http"):
authorization_code = "" return urlparse(instance.uri).netloc
while not authorization_code: return instance.uri
authorization_code = input("Authorization code: ")
print_out("\nRequesting access token...")
response = api.request_access_token(app, authorization_code)
return create_user(app, response['access_token'])

Wyświetl plik

@ -1,5 +1,6 @@
from toot.cli.base import cli, Context # noqa from toot.cli.base import cli, Context # noqa
from toot.cli.auth import *
from toot.cli.accounts import * from toot.cli.accounts import *
from toot.cli.lists import * from toot.cli.lists import *
from toot.cli.post import * from toot.cli.post import *

Wyświetl plik

@ -4,9 +4,8 @@ import json as pyjson
from typing import BinaryIO, Optional from typing import BinaryIO, Optional
from toot import api from toot import api
from toot.cli.base import cli, json_option, Context, pass_context from toot.cli.base import PRIVACY_CHOICES, cli, json_option, Context, pass_context
from toot.cli.validators import validate_language from toot.cli.validators import validate_language
from toot.console import PRIVACY_CHOICES
from toot.output import print_acct_list from toot.output import print_acct_list

143
toot/cli/auth.py 100644
Wyświetl plik

@ -0,0 +1,143 @@
import click
import platform
import sys
import webbrowser
from toot import api, config, __version__
from toot.auth import get_or_create_app, login_auth_code, login_username_password
from toot.cli.base import cli
from toot.cli.validators import validate_instance
instance_option = click.option(
"--instance", "-i", "base_url",
prompt="Enter instance URL",
default="https://mastodon.social",
callback=validate_instance,
help="""Domain or base URL of the instance to log into,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
)
@cli.command()
def auth():
"""Show logged in accounts and instances"""
config_data = config.load_config()
if not config_data["users"]:
click.echo("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
click.echo("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
uid = click.style(uid, fg="green")
active_label = click.style(active_label, fg="yellow")
click.echo(f"* {uid} {active_label}")
path = config.get_config_file_path()
path = click.style(path, "blue")
click.echo(f"\nAuth tokens are stored in: {path}")
@cli.command()
def env():
"""Print environment information for inclusion in bug reports."""
click.echo(f"toot {__version__}")
click.echo(f"Python {sys.version}")
click.echo(platform.platform())
@cli.command(name="login_cli")
@instance_option
@click.option("--email", "-e", help="Email address to log in with", prompt=True)
@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True)
def login_cli(base_url: str, email: str, password: str):
"""
Log into an instance from the console (not recommended)
Does NOT support two factor authentication, may not work on instances
other than Mastodon, mostly useful for scripting.
"""
app = get_or_create_app(base_url)
login_username_password(app, email, password)
click.secho("✓ Successfully logged in.", fg="green")
click.echo("Access token saved to config at: ", nl=False)
click.secho(config.get_config_file_path(), fg="green")
LOGIN_EXPLANATION = """This authentication method requires you to log into your
Mastodon instance in your browser, where you will be asked to authorize toot to
access your account. When you do, you will be given an authorization code which
you need to paste here.""".replace("\n", " ")
@cli.command()
@instance_option
def login(base_url: str):
"""Log into an instance using your browser (recommended)"""
app = get_or_create_app(base_url)
url = api.get_browser_login_url(app)
click.echo(click.wrap_text(LOGIN_EXPLANATION))
click.echo("\nLogin URL:")
click.echo(url)
yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False)
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = click.prompt("Authorization code")
login_auth_code(app, authorization_code)
click.echo()
click.secho("✓ Successfully logged in.", fg="green")
@cli.command()
@click.argument("account", required=False)
def logout(account: str):
"""Log out of ACCOUNT, delete stored access keys"""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to log out:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.delete_user(user)
click.secho(f"✓ Account {account} logged out", fg="green")
@cli.command()
@click.argument("account", required=False)
def activate(account: str):
"""Switch to logged in ACCOUNT."""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to activate:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.activate_user(user)
click.secho(f"✓ Account {account} activated", fg="green")
def _get_accounts_list() -> str:
accounts = config.load_config()["users"].keys()
if not accounts:
raise click.ClickException("You're not logged into any accounts")
return "\n".join([f"* {acct}" for acct in accounts])

Wyświetl plik

@ -1,12 +1,29 @@
import logging
import sys
import click import click
import logging
import os
import sys
from click.testing import Result
from functools import wraps from functools import wraps
from toot import App, User, config, __version__ from toot import App, User, config, __version__
from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar
PRIVACY_CHOICES = ["public", "unlisted", "private"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
# Type alias for run commands
Run = Callable[..., Result]
def get_default_visibility() -> str:
return os.getenv("TOOT_POST_VISIBILITY", "public")
# Tweak the Click context # Tweak the Click context
# https://click.palletsprojects.com/en/8.1.x/api/#context # https://click.palletsprojects.com/en/8.1.x/api/#context
CONTEXT = dict( CONTEXT = dict(

Wyświetl plik

@ -8,8 +8,8 @@ from typing import Optional, Tuple
from toot import api from toot import api
from toot.cli.base import cli, json_option, pass_context, Context from toot.cli.base import cli, json_option, pass_context, Context
from toot.cli.base import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility
from toot.cli.validators import validate_duration, validate_language from toot.cli.validators import validate_duration, validate_language
from toot.console import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility
from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input
from toot.utils.datetime import parse_datetime from toot.utils.datetime import parse_datetime

Wyświetl plik

@ -1,8 +1,8 @@
import click import click
from toot import api from toot import api
from toot.console import VISIBILITY_CHOICES, get_default_visibility
from toot.cli.base import cli, json_option, Context, pass_context from toot.cli.base import cli, json_option, Context, pass_context
from toot.cli.base import VISIBILITY_CHOICES, get_default_visibility
from toot.output import print_table from toot.output import print_table

Wyświetl plik

@ -1,8 +1,11 @@
import click import click
import re import re
from click import Context
from typing import Optional
def validate_language(ctx, param, value):
def validate_language(ctx: Context, param: str, value: Optional[str]):
if value is None: if value is None:
return None return None
@ -13,7 +16,7 @@ def validate_language(ctx, param, value):
raise click.BadParameter("Language should be a two letter abbreviation.") raise click.BadParameter("Language should be a two letter abbreviation.")
def validate_duration(ctx, param, value: str) -> int: def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]:
if value is None: if value is None:
return None return None
@ -43,3 +46,15 @@ def validate_duration(ctx, param, value: str) -> int:
raise click.BadParameter("Empty duration") raise click.BadParameter("Empty duration")
return duration return duration
def validate_instance(ctx: click.Context, param: str, value: Optional[str]):
"""
Instance can be given either as a base URL or the domain name.
Return the base URL.
"""
if not value:
return None
value = value.rstrip("/")
return value if value.startswith("http") else f"https://{value}"

Wyświetl plik

@ -3,6 +3,7 @@ import os
from functools import wraps from functools import wraps
from os.path import dirname, join from os.path import dirname, join
from typing import Optional
from toot import User, App, get_config_dir from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError from toot.exceptions import ConsoleError
@ -85,7 +86,7 @@ def get_user_app(user_id):
return extract_user_app(load_config(), user_id) return extract_user_app(load_config(), user_id)
def load_app(instance): def load_app(instance: str) -> Optional[App]:
config = load_config() config = load_config()
if instance in config['apps']: if instance in config['apps']:
return App(**config['apps'][instance]) return App(**config['apps'][instance])