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

Improve password management #1788

Merged
merged 1 commit into from
Dec 26, 2019
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
27 changes: 9 additions & 18 deletions poetry/console/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from cleo import option

from poetry.factory import Factory
from poetry.utils.helpers import keyring_repository_password_del
from poetry.utils.helpers import keyring_repository_password_set

from .command import Command

Expand Down Expand Up @@ -181,11 +179,14 @@ def handle(self):
# handle auth
m = re.match(r"^(http-basic|pypi-token)\.(.+)", self.argument("key"))
if m:
from poetry.utils.password_manager import PasswordManager

password_manager = PasswordManager(config)
if self.option("unset"):
keyring_repository_password_del(config, m.group(2))
config.auth_config_source.remove_property(
"{}.{}".format(m.group(1), m.group(2))
)
if m.group(1) == "http-basic":
password_manager.delete_http_password(m.group(2))
elif m.group(1) == "pypi-token":
password_manager.delete_pypi_token(m.group(2))

return 0

Expand All @@ -203,15 +204,7 @@ def handle(self):
username = values[0]
password = values[1]

property_value = dict(username=username)
try:
keyring_repository_password_set(m.group(2), username, password)
except RuntimeError:
property_value.update(password=password)

config.auth_config_source.add_property(
"{}.{}".format(m.group(1), m.group(2)), property_value
)
password_manager.set_http_password(m.group(2), username, password)
elif m.group(1) == "pypi-token":
if len(values) != 1:
raise ValueError(
Expand All @@ -220,9 +213,7 @@ def handle(self):

token = values[0]

config.auth_config_source.add_property(
"{}.{}".format(m.group(1), m.group(2)), token
)
password_manager.set_pypi_token(m.group(2), token)

return 0

Expand Down
2 changes: 1 addition & 1 deletion poetry/console/config/application_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def register_command_loggers(

io = event.io

loggers = ["poetry.packages.package"]
loggers = ["poetry.packages.package", "poetry.utils.password_manager"]

loggers += command.loggers

Expand Down
8 changes: 5 additions & 3 deletions poetry/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ def create_legacy_repository(
): # type: (Dict[str, str], Config) -> LegacyRepository
from .repositories.auth import Auth
from .repositories.legacy_repository import LegacyRepository
from .utils.helpers import get_client_cert, get_cert, get_http_basic_auth
from .utils.helpers import get_client_cert, get_cert
from .utils.password_manager import PasswordManager

if "url" in source:
# PyPI-like repository
Expand All @@ -242,11 +243,12 @@ def create_legacy_repository(
else:
raise RuntimeError("Unsupported source specified")

password_manager = PasswordManager(auth_config)
name = source["name"]
url = source["url"]
credentials = get_http_basic_auth(auth_config, name)
credentials = password_manager.get_http_auth(name)
if credentials:
auth = Auth(url, credentials[0], credentials[1])
auth = Auth(url, credentials["username"], credentials["password"])
else:
auth = None

Expand Down
11 changes: 6 additions & 5 deletions poetry/masonry/publishing/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from poetry.utils.helpers import get_cert
from poetry.utils.helpers import get_client_cert
from poetry.utils.helpers import get_http_basic_auth
from poetry.utils.password_manager import PasswordManager

from .uploader import Uploader

Expand All @@ -20,6 +20,7 @@ def __init__(self, poetry, io):
self._package = poetry.package
self._io = io
self._uploader = Uploader(poetry, io)
self._password_manager = PasswordManager(poetry.config)

@property
def files(self):
Expand Down Expand Up @@ -60,21 +61,21 @@ def publish(self, repository_name, username, password, cert=None, client_cert=No

if not (username and password):
# Check if we have a token first
token = self._poetry.config.get("pypi-token.{}".format(repository_name))
token = self._password_manager.get_pypi_token(repository_name)
if token:
logger.debug("Found an API token for {}.".format(repository_name))
username = "__token__"
password = token
else:
auth = get_http_basic_auth(self._poetry.config, repository_name)
auth = self._password_manager.get_http_auth(repository_name)
if auth:
logger.debug(
"Found authentication information for {}.".format(
repository_name
)
)
username = auth[0]
password = auth[1]
username = auth["username"]
password = auth["password"]

resolved_client_cert = client_cert or get_client_cert(
self._poetry.config, repository_name
Expand Down
52 changes: 0 additions & 52 deletions poetry/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@
from typing import List
from typing import Optional

from keyring import delete_password
from keyring import get_password
from keyring import set_password
from keyring.errors import KeyringError

from poetry.config.config import Config
from poetry.utils._compat import Path
from poetry.version import Version
Expand Down Expand Up @@ -95,53 +90,6 @@ def parse_requires(requires): # type: (str) -> List[str]
return requires_dist


def keyring_service_name(repository_name): # type: (str) -> str
return "{}-{}".format("poetry-repository", repository_name)


def keyring_repository_password_get(
repository_name, username
): # type: (str, str) -> Optional[str]
try:
return get_password(keyring_service_name(repository_name), username)
except (RuntimeError, KeyringError):
return None


def keyring_repository_password_set(
repository_name, username, password
): # type: (str, str, str) -> None
try:
set_password(keyring_service_name(repository_name), username, password)
except (RuntimeError, KeyringError):
raise RuntimeError("Failed to store password in keyring")


def keyring_repository_password_del(
config, repository_name
): # type: (Config, str) -> None
try:
repo_auth = config.get("http-basic.{}".format(repository_name))
if repo_auth and "username" in repo_auth:
delete_password(
keyring_service_name(repository_name), repo_auth["username"]
)
except (RuntimeError, KeyringError):
pass


def get_http_basic_auth(
config, repository_name
): # type: (Config, str) -> Optional[tuple]
repo_auth = config.get("http-basic.{}".format(repository_name))
if repo_auth:
username, password = repo_auth["username"], repo_auth.get("password")
if password is None:
password = keyring_repository_password_get(repository_name, username)
return username, password
return None


def get_cert(config, repository_name): # type: (Config, str) -> Optional[Path]
cert = config.get("certificates.{}.cert".format(repository_name))
if cert:
Expand Down
184 changes: 184 additions & 0 deletions poetry/utils/password_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import logging


logger = logging.getLogger(__name__)


class PasswordManagerError(Exception):

pass


class KeyRingError(Exception):

pass


class KeyRing:
def __init__(self, namespace):
self._namespace = namespace
self._is_available = True

self._check()

def is_available(self):
return self._is_available

def get_password(self, name, username):
if not self.is_available():
return

import keyring
import keyring.errors

name = self.get_entry_name(name)

try:
return keyring.get_password(name, username)
except (RuntimeError, keyring.errors.KeyringError):
raise KeyRingError(
"Unable to retrieve the password for {} from the key ring".format(name)
)

def set_password(self, name, username, password):
if not self.is_available():
return

import keyring
import keyring.errors

name = self.get_entry_name(name)

try:
keyring.set_password(name, username, password)
except (RuntimeError, keyring.errors.KeyringError) as e:
raise KeyRingError(
"Unable to store the password for {} in the key ring: {}".format(
name, str(e)
)
)

def delete_password(self, name, username):
if not self.is_available():
return

import keyring
import keyring.errors

name = self.get_entry_name(name)

try:
keyring.delete_password(name, username)
except (RuntimeError, keyring.errors.KeyringError):
raise KeyRingError(
"Unable to delete the password for {} from the key ring".format(name)
)

def get_entry_name(self, name):
return "{}-{}".format(self._namespace, name)

def _check(self):
try:
import keyring
except Exception as e:
logger.debug("An error occurred while importing keyring: {}".format(str(e)))
self._is_available = False

return

backend = keyring.get_keyring()
name = backend.name.split(" ")[0]
if name == "fail":
logger.debug("No suitable keyring backend found")
self._is_available = False
elif "plaintext" in backend.name.lower():
logger.debug("Only a plaintext keyring backend is available. Not using it.")
self._is_available = False
elif name == "chainer":
try:
import keyring.backend

backends = keyring.backend.get_all_keyring()

self._is_available = any(
[
b.name.split(" ")[0] not in ["chainer", "fail"]
and "plaintext" not in b.name.lower()
for b in backends
]
)
except Exception:
self._is_available = False

if not self._is_available:
logger.warning("No suitable keyring backends were found")


class PasswordManager:
def __init__(self, config):
self._config = config
self._keyring = KeyRing("poetry-repository")
if not self._keyring.is_available():
logger.warning("Using a plaintext file to store and retrieve credentials")

@property
def keyring(self):
return self._keyring

def set_pypi_token(self, name, token):
if not self._keyring.is_available():
self._config.auth_config_source.add_property(
"pypi-token.{}".format(name), token
)
else:
self._keyring.set_password(name, "__token__", token)

def get_pypi_token(self, name):
if not self._keyring.is_available():
return self._config.get("pypi-token.{}".format(name))

return self._keyring.get_password(name, "__token__")

def delete_pypi_token(self, name):
if not self._keyring.is_available():
return self._config.auth_config_source.remove_property(
"pypi-token.{}".format(name)
)

self._keyring.delete_password(name, "__token__")

def get_http_auth(self, name):
auth = self._config.get("http-basic.{}".format(name))
if not auth:
return None

username, password = auth["username"], auth.get("password")
if password is None:
password = self._keyring.get_password(name, username)

return {
"username": username,
"password": password,
}

def set_http_password(self, name, username, password):
auth = {"username": username}

if not self._keyring.is_available():
auth["password"] = password
else:
self._keyring.set_password(name, username, password)

self._config.auth_config_source.add_property("http-basic.{}".format(name), auth)

def delete_http_password(self, name):
auth = self.get_http_auth(name)
if not auth or "username" not in auth:
return

try:
self._keyring.delete_password(name, auth["username"])
except KeyRingError:
pass

self._config.auth_config_source.remove_property("http-basic.{}".format(name))
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ def auth_config_source():

@pytest.fixture
def config(config_source, auth_config_source, mocker):
import keyring
from keyring.backends.fail import Keyring

keyring.set_keyring(Keyring())

c = Config()
c.merge(config_source.config)
c.set_config_source(config_source)
Expand Down
Loading