Skip to content

Commit

Permalink
Improve password management (python-poetry#1788)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdispater authored and shenek committed Dec 31, 2019
1 parent af28d22 commit 4f53444
Show file tree
Hide file tree
Showing 10 changed files with 420 additions and 221 deletions.
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 @@ -52,6 +52,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

0 comments on commit 4f53444

Please sign in to comment.