Commit 964d004b by Arun Babu Neelicattu Committed by Steph Samson

Use system keyring for passwords when available (#774)

This change introduces the use of system keyring to store repository
passwords when one is available. Passwords are added to and removed from
the keyring using the config command.

Resolves: #210
parent 83f3d1ff
...@@ -49,6 +49,10 @@ If you do not specify the password you will be prompted to write it. ...@@ -49,6 +49,10 @@ If you do not specify the password you will be prompted to write it.
You can also specify the username and password when using the `publish` command You can also specify the username and password when using the `publish` command
with the `--username` and `--password` options. with the `--username` and `--password` options.
If a system keyring is available and supported, the password is stored to and retrieved from the keyring. In the above example, the credential will be stored using the name `poetry-repository-pypi`. If access to keyring fails or is unsupported, this will fall back to writing the password to the `auth.toml` file along with the username.
Keyring support is enabled using the [keyring library](https://pypi.org/project/keyring/). For more information on supported backends refer to the [library documentation](https://keyring.readthedocs.io/en/latest/?badge=latest).
### Install dependencies from a private repository ### Install dependencies from a private repository
Now that you can publish to your private repository, you need to be able to Now that you can publish to your private repository, you need to be able to
......
...@@ -4,9 +4,12 @@ import re ...@@ -4,9 +4,12 @@ import re
from cleo import argument from cleo import argument
from cleo import option from cleo import option
from poetry.utils.helpers import (
keyring_repository_password_del,
keyring_repository_password_set,
)
from .command import Command from .command import Command
TEMPLATE = """[settings] TEMPLATE = """[settings]
[repositories] [repositories]
...@@ -198,6 +201,7 @@ To remove a repository (repo is a short alias for repositories): ...@@ -198,6 +201,7 @@ To remove a repository (repo is a short alias for repositories):
"There is no {} {} defined".format(m.group(2), m.group(1)) "There is no {} {} defined".format(m.group(2), m.group(1))
) )
keyring_repository_password_del(self._auth_config, m.group(2))
self._auth_config.remove_property( self._auth_config.remove_property(
"{}.{}".format(m.group(1), m.group(2)) "{}.{}".format(m.group(1), m.group(2))
) )
...@@ -218,9 +222,14 @@ To remove a repository (repo is a short alias for repositories): ...@@ -218,9 +222,14 @@ To remove a repository (repo is a short alias for repositories):
username = values[0] username = values[0]
password = values[1] 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)
self._auth_config.add_property( self._auth_config.add_property(
"{}.{}".format(m.group(1), m.group(2)), "{}.{}".format(m.group(1), m.group(2)), property_value
{"username": username, "password": password},
) )
return 0 return 0
......
...@@ -6,8 +6,11 @@ import tempfile ...@@ -6,8 +6,11 @@ import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from typing import List from typing import List
from typing import NoReturn
from typing import Optional from typing import Optional
from typing import Union
from keyring import delete_password, set_password, get_password
from keyring.errors import KeyringError
from poetry.config import Config from poetry.config import Config
from poetry.version import Version from poetry.version import Version
...@@ -83,13 +86,50 @@ def parse_requires(requires): # type: (str) -> List[str] ...@@ -83,13 +86,50 @@ def parse_requires(requires): # type: (str) -> List[str]
return requires_dist 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) -> NoReturn
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) -> NoReturn
try:
repo_auth = config.setting("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( def get_http_basic_auth(
config, repository_name config, repository_name
): # type: (Config, str) -> Optional[tuple] ): # type: (Config, str) -> Optional[tuple]
repo_auth = config.setting("http-basic.{}".format(repository_name)) repo_auth = config.setting("http-basic.{}".format(repository_name))
if repo_auth: if repo_auth:
return repo_auth["username"], repo_auth.get("password") 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 return None
......
...@@ -47,6 +47,10 @@ glob2 = { version = "^0.6", python = "~2.7 || ~3.4" } ...@@ -47,6 +47,10 @@ glob2 = { version = "^0.6", python = "~2.7 || ~3.4" }
virtualenv = { version = "^16.0", python = "~2.7" } virtualenv = { version = "^16.0", python = "~2.7" }
# functools32 is needed for Python 2.7 # functools32 is needed for Python 2.7
functools32 = { version = "^3.2.3", python = "~2.7" } functools32 = { version = "^3.2.3", python = "~2.7" }
keyring = [
{ version = "^18.0", python = "~2.7 || ~3.4" },
{ version = "^19.0", python = "^3.5" }
]
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^4.1" pytest = "^4.1"
......
from uuid import uuid4
import pytest
from keyring import set_keyring, get_keyring
from keyring.backend import KeyringBackend
from keyring.errors import KeyringError
from poetry.utils.helpers import (
keyring_service_name,
keyring_repository_password_get,
keyring_repository_password_set,
keyring_repository_password_del,
)
class DictKeyring(KeyringBackend):
priority = 1
def __init__(self):
self._storage = dict()
def set_password(self, servicename, username, password):
if servicename not in self._storage:
self._storage[servicename] = dict()
self._storage[servicename][username] = password
def get_password(self, servicename, username):
if servicename in self._storage:
return self._storage[servicename].get(username)
def delete_password(self, servicename, username):
if servicename in self._storage:
if username in self._storage[servicename]:
del self._storage[servicename][username]
if not self._storage[servicename]:
del self._storage[servicename]
class BrokenKeyring(KeyringBackend):
priority = 1
def set_password(self, servicename, username, password):
raise KeyringError()
def get_password(self, servicename, username):
raise KeyringError()
def delete_password(self, servicename, username):
raise KeyringError()
@pytest.fixture
def keyring(): # type: () -> KeyringBackend
k = DictKeyring()
set_keyring(k)
return k
@pytest.fixture
def broken_keyring(): # type: () -> KeyringBackend
k = BrokenKeyring()
set_keyring(k)
return k
@pytest.fixture
def repository(): # type: () -> str
return "test"
@pytest.fixture
def username(): # type: () -> str
return "username"
@pytest.fixture
def password(): # type: () -> str
return str(uuid4())
def test_keyring_repository_password_get(keyring, repository, username, password):
keyring.set_password(keyring_service_name(repository), username, password)
assert keyring_repository_password_get(repository, username) == password
def test_keyring_repository_password_get_not_set(keyring, repository, username):
assert keyring.get_password(keyring_service_name(repository), username) is None
assert keyring_repository_password_get(repository, username) is None
def test_keyring_repository_password_get_broken(broken_keyring):
assert get_keyring() == broken_keyring
assert keyring_repository_password_get("repository", "username") is None
def test_keyring_repository_password_set(keyring, repository, username, password):
keyring_repository_password_set(repository, username, password)
assert keyring.get_password(keyring_service_name(repository), username) == password
def test_keyring_repository_password_set_broken(broken_keyring):
assert get_keyring() == broken_keyring
with pytest.raises(RuntimeError):
keyring_repository_password_set(repository, "username", "password")
def test_keyring_repository_password_del(
keyring, config, repository, username, password
):
keyring.set_password(keyring_service_name(repository), username, password)
config.add_property("http-basic.{}.username".format(repository), username)
keyring_repository_password_del(config, repository)
assert keyring.get_password(keyring_service_name(repository), username) is None
def test_keyring_repository_password_del_not_set(keyring, config, repository, username):
config.add_property("http-basic.{}.username".format(repository), username)
keyring_repository_password_del(config, repository)
assert keyring.get_password(keyring_service_name(repository), username) is None
def test_keyring_repository_password_del_broken(broken_keyring, config):
assert get_keyring() == broken_keyring
keyring_repository_password_del(config, "repository")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment