From d0e8ac167ac02e5ea9cc2553037bb2b447884f08 Mon Sep 17 00:00:00 2001 From: "Dr. Phil Maffettone" <43007690+maffettone@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:27:56 -0500 Subject: [PATCH] feat: tty free login (#816) * feat: hook into auth with username and pass * test: update context manager with new signature * test: fix lambda and trim getpass monkeypatch * test: add tests for hook and some context to raises * doc: update changelog * maint: flake8 lint * fix: ensure idempotency in context manager * Update tiled/_tests/test_authentication.py Co-authored-by: Dan Allan * feat: callable reauth, remove redundant CannotRefresh raise * test: callable reauth update tests * test: default windows behavior sees prompt avail * Update tiled/client/context.py Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> * Update tiled/_tests/test_authentication.py Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> * refactor: move initial context setting out of try block --------- Co-authored-by: Dan Allan Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> --- CHANGELOG.md | 1 + tiled/_tests/test_authentication.py | 40 ++++++++++++++++++++++++++++- tiled/_tests/utils.py | 24 ++++++++--------- tiled/client/context.py | 28 +++++++++++++------- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4e970ee..28675aa9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,7 @@ Write the date in place of the "Unreleased" in the case a new version is release - A new method `DataFrameClient.append_partition`. - Support for registering Groups and Datasets _within_ an HDF5 file - Tiled version is logged by server at startup. +- Hook to authentication prompt to make password login available without TTY. ### Fixed diff --git a/tiled/_tests/test_authentication.py b/tiled/_tests/test_authentication.py index 6995d4cec..9d9163c3a 100644 --- a/tiled/_tests/test_authentication.py +++ b/tiled/_tests/test_authentication.py @@ -13,7 +13,7 @@ from ..adapters.mapping import MapAdapter from ..client import Context, from_context from ..client.auth import CannotRefreshAuthentication -from ..client.context import clear_default_identity, get_default_identity +from ..client.context import CannotPrompt, clear_default_identity, get_default_identity from ..server import authentication from ..server.app import build_app_from_config from .utils import fail_with_status_code @@ -94,6 +94,44 @@ def test_password_auth(enter_username_password, config): from_context(context, username="alice") +def test_password_auth_hook(config): + """Verify behavior with user-defined 'prompt_for_reauthentication' hook.""" + with Context.from_app(build_app_from_config(config)) as context: + # Log in as Alice. + context.authenticate(username="alice", password="secret1") + assert "authenticated as 'alice'" in repr(context) + + # Attempting to reauth without a prompt hook should raise. + with pytest.raises(CannotPrompt): + context.http_client.auth.sync_clear_token("refresh_token") + context.http_client.auth.sync_clear_token("access_token") + context.authenticate(username="alice", prompt_for_reauthentication=False) + + # Log in as Bob. + context.authenticate(username="bob", password="secret2") + assert "authenticated as 'bob'" in repr(context) + context.logout() + + # Bob's password should not work for Alice. + with fail_with_status_code(HTTP_401_UNAUTHORIZED): + context.authenticate(username="alice", password="secret2") + + # Empty password should not work. + with fail_with_status_code(HTTP_401_UNAUTHORIZED): + context.authenticate(username="alice", password="") + + # Hook for reauthenticating as Alice should succeeed + context.authenticate(username="alice", password="secret1") + assert "authenticated as 'alice'" in repr(context) + context.http_client.auth.sync_clear_token("refresh_token") + context.http_client.auth.sync_clear_token("access_token") + context.authenticate( + username="alice", + prompt_for_reauthentication=lambda u, p: ("alice", "secret1"), + ) + assert "authenticated as 'alice'" in repr(context) + + def test_logout(enter_username_password, config, tmpdir): """ Logging out revokes the session, such that it cannot be refreshed. diff --git a/tiled/_tests/utils.py b/tiled/_tests/utils.py index 25bad3929..ffbe8d2d0 100644 --- a/tiled/_tests/utils.py +++ b/tiled/_tests/utils.py @@ -1,5 +1,4 @@ import contextlib -import getpass import sqlite3 import sys import tempfile @@ -55,22 +54,23 @@ async def temp_postgres(uri): @contextlib.contextmanager def enter_username_password(username, password): """ - Override getpass, used like: + Override getpass, when prompt_for_credentials with username only + used like: - >>> with enter_password(...): - ... # Run code that calls getpass.getpass(). + >>> with enter_username_password(...): + ... # Run code that calls prompt_for_credentials and subsequently getpass.getpass(). """ original_prompt = context.PROMPT_FOR_REAUTHENTICATION - original_getusername = context.prompt_for_username - original_getpass = getpass.getpass + original_credentials = context.prompt_for_credentials context.PROMPT_FOR_REAUTHENTICATION = True - context.prompt_for_username = lambda u: username - setattr(getpass, "getpass", lambda: password) - yield - setattr(getpass, "getpass", original_getpass) - context.PROMPT_FOR_REAUTHENTICATION = original_prompt - context.prompt_for_username = original_getusername + context.prompt_for_credentials = lambda u, p: (username, password) + try: + # Ensures that raise in calling routine does not prevent context from being exited. + yield + finally: + context.PROMPT_FOR_REAUTHENTICATION = original_prompt + context.prompt_for_credentials = original_credentials class URL_LIMITS(IntEnum): diff --git a/tiled/client/context.py b/tiled/client/context.py index 20af48d90..3dc54feda 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -7,6 +7,7 @@ import urllib.parse import warnings from pathlib import Path +from typing import Callable, Optional, Union import httpx import platformdirs @@ -23,17 +24,22 @@ PROMPT_FOR_REAUTHENTICATION = None -def prompt_for_username(username): +def prompt_for_credentials(username, password): """ Utility function that displays a username prompt. """ - if username: + if username is not None and password is not None: + # If both are provided, return them as-is, without prompting. + # This is particularly useful for GUI clients without a TTY Console. + return username, password + elif username: username_reprompt = input(f"Username [{username}]: ") if len(username_reprompt.strip()) != 0: username = username_reprompt else: username = input("Username: ") - return username + password = getpass.getpass() + return username, password class Context: @@ -481,8 +487,10 @@ def authenticate( self, username=UNSET, provider=UNSET, - prompt_for_reauthentication=UNSET, + prompt_for_reauthentication: Optional[Union[bool, Callable]] = UNSET, set_default=True, + *, + password=UNSET, ): """ See login. This is for programmatic use. @@ -533,14 +541,12 @@ def authenticate( except CannotRefreshAuthentication: # Continue below, where we will prompt for log in. self.http_client.auth = None - if not prompt_for_reauthentication: - raise else: # We have a live session for the specified provider and username already. # No need to log in again. return - if not prompt_for_reauthentication: + if not prompt_for_reauthentication and password is UNSET: raise CannotPrompt( """Authentication is needed but Tiled has detected that it is running in a 'headless' context where it cannot prompt the user to provide @@ -549,14 +555,18 @@ def authenticate( - If Tiled has detected this wrongly, pass prompt_for_reauthentication=True to force it to prompt. - Provide an API key in the environment variable TILED_API_KEY for Tiled to use. +- Pass prompt_for_reauthentication=Callable, to generate the reauthentication via your application hook. """ ) self.http_client.auth = None mode = spec["mode"] auth_endpoint = spec["links"]["auth_endpoint"] if mode == "password": - username = prompt_for_username(username) - password = getpass.getpass() + username, password = ( + prompt_for_reauthentication(username, password) + if isinstance(prompt_for_reauthentication, Callable) + else prompt_for_credentials(username, password) + ) form_data = { "grant_type": "password", "username": username,