Skip to content

Commit

Permalink
feat: tty free login (#816)
Browse files Browse the repository at this point in the history
* 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 <daniel.b.allan@gmail.com>

* 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 <daniel.b.allan@gmail.com>
Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com>
  • Loading branch information
3 people authored Nov 25, 2024
1 parent 79bb4dd commit d0e8ac1
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 22 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 39 additions & 1 deletion tiled/_tests/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 12 additions & 12 deletions tiled/_tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import contextlib
import getpass
import sqlite3
import sys
import tempfile
Expand Down Expand Up @@ -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):
Expand Down
28 changes: 19 additions & 9 deletions tiled/client/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import urllib.parse
import warnings
from pathlib import Path
from typing import Callable, Optional, Union

import httpx
import platformdirs
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down

0 comments on commit d0e8ac1

Please sign in to comment.