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 erase
coverage run
coverage html
coverage html --omit toot/tui/*
coverage report
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:
date: 2023-11-23
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
from toot.console import duration
from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.utils import urlencode_url
@ -163,6 +163,9 @@ def test_wc_wrap_indented():
def test_duration():
def duration(value):
return validate_duration(None, None, value)
# Long hand
assert duration("1 second") == 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("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("100")
# Wrong order
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("1m1d")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
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()
def login(app, username, password):
def login(app: App, username: str, password: str):
url = app.base_url + '/oauth/token'
data = {
@ -152,16 +152,10 @@ def login(app, username, password):
'scope': SCOPES,
}
response = http.anon_post(url, data=data, allow_redirects=False)
# If auth fails, it redirects to the login page
if response.is_redirect:
raise AuthenticationError()
return response.json()
return http.anon_post(url, data=data).json()
def get_browser_login_url(app):
def get_browser_login_url(app: App) -> str:
"""Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"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'
data = {

Wyświetl plik

@ -1,18 +1,19 @@
import sys
import webbrowser
from builtins import input
from getpass import getpass
from toot import api, config, DEFAULT_INSTANCE, User, App
from toot import api, config, User, App
from toot.entities import from_dict, Instance
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
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:
print_out("Registering application...")
response = api.create_app(base_url)
except ApiError:
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'])
config.save_app(app)
print_out("Application tokens saved.")
return app
def create_app_interactive(base_url):
if not base_url:
print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="")
base_url = input()
if not base_url:
base_url = DEFAULT_INSTANCE
domain = get_instance_domain(base_url)
def get_or_create_app(base_url: str) -> App:
instance = find_instance(base_url)
domain = _get_instance_domain(instance)
return config.load_app(domain) or register_app(domain, base_url)
def get_instance_domain(base_url):
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):
def create_user(app: App, access_token: str) -> User:
# Username is not yet known at this point, so fetch it from Mastodon
user = User(app.instance, None, access_token)
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)
print_out("Access token saved to config at: <green>{}</green>".format(
config.get_config_file_path()))
return user
def login_interactive(app, email=None):
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>")
def login_username_password(app: App, email: str, password: str) -> User:
try:
print_out("Authenticating...")
response = api.login(app, email, password)
except ApiError:
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response['access_token'])
return create_user(app, response["access_token"])
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_auth_code(app: App, authorization_code: str) -> User:
try:
response = api.request_access_token(app, authorization_code)
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response["access_token"])
def login_browser_interactive(app):
url = api.get_browser_login_url(app)
print_out(BROWSER_LOGIN_EXPLANATION)
def _get_instance_domain(instance: Instance) -> str:
"""Extracts the instance domain name.
print_out("This is the login URL:")
print_out(url)
print_out("")
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.
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)
return create_user(app, response['access_token'])
TODO: when updating to v2 instance endpoint, this field has been renamed to
`domain`
"""
if instance.uri.startswith("http"):
return urlparse(instance.uri).netloc
return instance.uri

Wyświetl plik

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

Wyświetl plik

@ -4,9 +4,8 @@ import json as pyjson
from typing import BinaryIO, Optional
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.console import PRIVACY_CHOICES
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 logging
import os
import sys
from click.testing import Result
from functools import wraps
from toot import App, User, config, __version__
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
# https://click.palletsprojects.com/en/8.1.x/api/#context
CONTEXT = dict(

Wyświetl plik

@ -8,8 +8,8 @@ from typing import Optional, Tuple
from toot import api
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.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.datetime import parse_datetime

Wyświetl plik

@ -1,8 +1,8 @@
import click
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 VISIBILITY_CHOICES, get_default_visibility
from toot.output import print_table

Wyświetl plik

@ -1,8 +1,11 @@
import click
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:
return None
@ -13,7 +16,7 @@ def validate_language(ctx, param, value):
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:
return None
@ -43,3 +46,15 @@ def validate_duration(ctx, param, value: str) -> int:
raise click.BadParameter("Empty 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 os.path import dirname, join
from typing import Optional
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
@ -85,7 +86,7 @@ def get_user_app(user_id):
return extract_user_app(load_config(), user_id)
def load_app(instance):
def load_app(instance: str) -> Optional[App]:
config = load_config()
if instance in config['apps']:
return App(**config['apps'][instance])