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

Add token support #16

Merged
merged 12 commits into from
Oct 21, 2023
74 changes: 47 additions & 27 deletions conda_auth/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@
from conda.models.channel import Channel

from .condarc import CondaRC, CondaRCError
from .constants import OAUTH2_NAME, HTTP_BASIC_AUTH_NAME
from .exceptions import CondaAuthError, InvalidCredentialsError
from .handlers import AuthManager, oauth2_manager, basic_auth_manager
from .exceptions import CondaAuthError
from .handlers import (
AuthManager,
basic_auth_manager,
token_auth_manager,
HTTP_BASIC_AUTH_NAME,
TOKEN_NAME,
)

# Constants
AUTH_MANAGER_MAPPING = {
OAUTH2_NAME: oauth2_manager,
HTTP_BASIC_AUTH_NAME: basic_auth_manager,
TOKEN_NAME: token_auth_manager,
}
SUCCESSFUL_LOGIN_MESSAGE = "Successfully logged in"
SUCCESSFUL_LOGOUT_MESSAGE = "Successfully logged out"

SUCCESSFUL_LOGIN_MESSAGE = "Successfully stored credentials"

SUCCESSFUL_LOGOUT_MESSAGE = "Successfully removed credentials"

SUCCESSFUL_COLOR = "green"
MAX_LOGIN_ATTEMPTS = 3

VALID_AUTH_CHOICES = tuple(AUTH_MANAGER_MAPPING.keys())


def parse_channel(ctx, param, value):
Expand All @@ -32,20 +42,25 @@ def get_auth_manager(options) -> tuple[str, AuthManager]:
"""
Based on CLI options provided, return the correct auth manager to use.
"""
auth_type = options.get("type") or options.get("auth")
auth_type = options.get("auth")

if auth_type is not None:
auth_manager = AUTH_MANAGER_MAPPING.get(auth_type)
if auth_manager is None:
raise CondaAuthError(
f'Invalid authentication type. Valid types are: "{HTTP_BASIC_AUTH_NAME}"'
f'Invalid authentication type. Valid types are: "{", ".join(VALID_AUTH_CHOICES)}"'
)

# we use http basic auth when username or password are present
# we use http basic auth when "username" or "password" are present
elif options.get("username") is not None or options.get("password") is not None:
auth_manager = basic_auth_manager
auth_type = HTTP_BASIC_AUTH_NAME

# we use token auth when "token" is present
elif options.get("token") is not None:
auth_manager = token_auth_manager
auth_type = TOKEN_NAME

# default authentication handler
else:
auth_manager = basic_auth_manager
Expand Down Expand Up @@ -76,34 +91,39 @@ def auth_wrapper(args):


@group.command("login")
@click.option("-u", "--username", help="Username to use for HTTP Basic Authentication")
@click.option("-p", "--password", help="Password to use for HTTP Basic Authentication")
@click.option(
"-u",
"--username",
help="Username to use for private channels using HTTP Basic Authentication",
)
@click.option(
"-p",
"--password",
help="Password to use for private channels using HTTP Basic Authentication",
)
@click.option(
"-t",
"--type",
help='Manually specify the type of authentication to use. Choices are: "http-basic"',
"--token",
help="Token to use for private channels using an API token",
)
@click.option(
"-a",
"--auth",
help="Specify the authentication type you would like to use",
type=click.Choice(VALID_AUTH_CHOICES),
)
travishathaway marked this conversation as resolved.
Show resolved Hide resolved
@click.argument("channel", callback=parse_channel)
def login(channel: Channel, **kwargs):
"""
Login to a channel
Log in to a channel by storing the credentials or tokens associated with it
"""
kwargs = {key: val for key, val in kwargs.items() if val is not None}
settings = get_channel_settings(channel.canonical_name) or {}
settings.update(kwargs)

auth_type, auth_manager = get_auth_manager(settings)
attempts = 0

while True:
try:
username = auth_manager.authenticate(channel, settings)
break
except InvalidCredentialsError as exc:
auth_manager.remove_channel_cache(channel.canonical_name)
attempts += 1
if attempts >= MAX_LOGIN_ATTEMPTS:
raise CondaAuthError(f"Max attempts reached; {exc}")

username = auth_manager.store(channel, settings)

click.echo(click.style(SUCCESSFUL_LOGIN_MESSAGE, fg=SUCCESSFUL_COLOR))

Expand All @@ -119,7 +139,7 @@ def login(channel: Channel, **kwargs):
@click.argument("channel", callback=parse_channel)
def logout(channel: Channel):
"""
Logout of a channel
Log out of a by removing any credentials or tokens associated with it.
travishathaway marked this conversation as resolved.
Show resolved Hide resolved
"""
settings = get_channel_settings(channel.canonical_name)

Expand Down
7 changes: 0 additions & 7 deletions conda_auth/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,4 @@

PLUGIN_NAME = "conda-auth"

# move to the handlers module
OAUTH2_NAME = "oauth2"

# move to the handlers module
HTTP_BASIC_AUTH_NAME = "http-basic"

# Error messages
LOGOUT_ERROR_MESSAGE = "Unable to logout."
12 changes: 7 additions & 5 deletions conda_auth/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# flake8: noqa: F401
from .base import AuthManager
from .oauth2 import (
OAuth2Manager,
OAuth2Handler,
manager as oauth2_manager,
)
from .basic_auth import (
BasicAuthManager,
BasicAuthHandler,
manager as basic_auth_manager,
HTTP_BASIC_AUTH_NAME,
)
from .token import (
TokenAuthManager,
TokenAuthHandler,
manager as token_auth_manager,
TOKEN_NAME,
)
46 changes: 2 additions & 44 deletions conda_auth/handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,8 @@

import conda.base.context
import keyring
import requests
from conda.gateways.connection.session import CondaSession
from conda.models.channel import Channel

from ..exceptions import InvalidCredentialsError

INVALID_CREDENTIALS_ERROR_MESSAGE = "Provided credentials are not correct."


class AuthManager(ABC):
"""
Expand All @@ -38,9 +32,9 @@ def hook_action(self, command: str) -> None:
channel.canonical_name in self._context.channels
and settings.get("auth") == self.get_auth_type()
):
self.authenticate(channel, settings)
self.store(channel, settings)

def authenticate(self, channel: Channel, settings: Mapping[str, str]) -> str:
def store(self, channel: Channel, settings: Mapping[str, str]) -> str:
"""
Used to retrieve credentials and store them on the ``cache`` property

Expand All @@ -52,7 +46,6 @@ def authenticate(self, channel: Channel, settings: Mapping[str, str]) -> str:
}
username, secret = self.fetch_secret(channel, extra_params)

verify_credentials(channel, self.get_auth_class())
self.save_credentials(channel, username, secret)

return username
Expand Down Expand Up @@ -93,15 +86,6 @@ def get_secret(self, channel_name: str) -> tuple[str | None, str | None]:

return secrets

def remove_channel_cache(self, channel_name: str) -> None:
"""
Removes the cached secret for the given channel name
"""
try:
del self._cache[channel_name]
except KeyError:
pass

travishathaway marked this conversation as resolved.
Show resolved Hide resolved
@abstractmethod
def _fetch_secret(
self, channel: Channel, settings: Mapping[str, str | None]
Expand Down Expand Up @@ -138,29 +122,3 @@ def get_auth_class(self) -> type:
Returns the authentication class to use (requests.auth.AuthBase subclass) for the given
authentication manager
"""


def verify_credentials(channel: Channel, auth_cls: type) -> None:
"""
Verify the credentials that have been currently set for the channel.

Raises exception if unable to make a successful request.

TODO:
We need a better way to tell if the credentials work. We might need
to fetch (or perform a HEAD request) on something specific like
repodata.json.
"""
for url in channel.base_urls:
session = CondaSession(auth=auth_cls(channel.canonical_name))
resp = session.head(url, allow_redirects=False)

try:
resp.raise_for_status()
except requests.exceptions.HTTPError as exc:
if exc.response.status_code == requests.codes["unauthorized"]:
error_message = INVALID_CREDENTIALS_ERROR_MESSAGE
else:
error_message = str(exc)

raise InvalidCredentialsError(error_message)
13 changes: 12 additions & 1 deletion conda_auth/handlers/basic_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@
from conda.models.channel import Channel
from conda.plugins.types import ChannelAuthBase

from ..constants import HTTP_BASIC_AUTH_NAME, LOGOUT_ERROR_MESSAGE, PLUGIN_NAME
from ..constants import LOGOUT_ERROR_MESSAGE, PLUGIN_NAME
from ..exceptions import CondaAuthError
from .base import AuthManager

USERNAME_PARAM_NAME = "username"
"""
Name of the configuration parameter where username information is stored
"""

PASSWORD_PARAM_NAME = "password"
"""
Name of the configuration parameter where password information is stored
"""

HTTP_BASIC_AUTH_NAME = "http-basic"
"""
Name used to refer to this authentication handler in configuration
"""


class BasicAuthManager(AuthManager):
Expand Down
107 changes: 0 additions & 107 deletions conda_auth/handlers/oauth2.py

This file was deleted.

Loading