Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: tty free login #816

Merged
merged 14 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,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:
maffettone marked this conversation as resolved.
Show resolved Hide resolved
# 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 appdirs
import httpx
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.
maffettone marked this conversation as resolved.
Show resolved Hide resolved
# 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
Loading