Commit 4d1e9753 by Brian Turek Committed by Sébastien Eustace

Add support for custom certificate authority and client certificates (#1325)

* Add custom certificate authority and client certificate support

* Add documentation on certificates

* Add cli options to publish for cert and custom ca

* Fix requests+pathlib problem and logic hole when BasicAuth+certs are used

* Rename custom-ca to cert

* Make black happy
parent 9b4adcf5
...@@ -3,4 +3,3 @@ repos: ...@@ -3,4 +3,3 @@ repos:
rev: stable rev: stable
hooks: hooks:
- id: black - id: black
language_version: python3.6
...@@ -74,6 +74,16 @@ export POETRY_HTTP_BASIC_PYPI_PASSWORD=password ...@@ -74,6 +74,16 @@ export POETRY_HTTP_BASIC_PYPI_PASSWORD=password
See [Using environment variables](/configuration#using-environment-variables) for more information See [Using environment variables](/configuration#using-environment-variables) for more information
on how to configure Poetry with environment variables. on how to configure Poetry with environment variables.
#### Custom certificate authority and mutual TLS authentication
Poetry supports repositories that are secured by a custom certificate authority as well as those that require
certificate-based client authentication. The following will configure the "foo" repository to validate the repository's
certificate using a custom certificate authority and use a client certificate (note that these config variables do not
both need to be set):
```bash
poetry config certificates.foo.cert /path/to/ca.pem
poetry config certificates.foo.client-cert /path/to/client.pem
```
### 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
...@@ -105,8 +115,10 @@ From now on, Poetry will also look for packages in your private repository. ...@@ -105,8 +115,10 @@ From now on, Poetry will also look for packages in your private repository.
If your private repository requires HTTP Basic Auth be sure to add the username and If your private repository requires HTTP Basic Auth be sure to add the username and
password to your `http-basic` configuration using the example above (be sure to use the password to your `http-basic` configuration using the example above (be sure to use the
same name that is in the `tool.poetry.source` section). Poetry will use these values same name that is in the `tool.poetry.source` section). If your repository requires either
to authenticate to your private repository when downloading or looking for packages. a custom certificate authority or client certificates, similarly refer to the example above to configure the
`certificates` section. Poetry will use these values to authenticate to your private repository when downloading or
looking for packages.
### Disabling the PyPI repository ### Disabling the PyPI repository
......
...@@ -228,6 +228,27 @@ To remove a repository (repo is a short alias for repositories): ...@@ -228,6 +228,27 @@ To remove a repository (repo is a short alias for repositories):
return 0 return 0
# handle certs
m = re.match(
r"(?:certificates)\.([^.]+)\.(cert|client-cert)", self.argument("key")
)
if m:
if self.option("unset"):
config.auth_config_source.remove_property(
"certificates.{}.{}".format(m.group(1), m.group(2))
)
return 0
if len(values) == 1:
config.auth_config_source.add_property(
"certificates.{}.{}".format(m.group(1), m.group(2)), values[0]
)
else:
raise ValueError("You must pass exactly 1 value")
return 0
raise ValueError("Setting {} does not exist".format(self.argument("key"))) raise ValueError("Setting {} does not exist".format(self.argument("key")))
def _handle_single_value(self, source, key, callbacks, values): def _handle_single_value(self, source, key, callbacks, values):
......
from cleo import option from cleo import option
from poetry.utils._compat import Path
from .command import Command from .command import Command
...@@ -14,6 +16,15 @@ class PublishCommand(Command): ...@@ -14,6 +16,15 @@ class PublishCommand(Command):
), ),
option("username", "u", "The username to access the repository.", flag=False), option("username", "u", "The username to access the repository.", flag=False),
option("password", "p", "The password to access the repository.", flag=False), option("password", "p", "The password to access the repository.", flag=False),
option(
"cert", None, "Certificate authority to access the repository.", flag=False
),
option(
"client-cert",
None,
"Client certificate to access the repository.",
flag=False,
),
option("build", None, "Build the package before publishing."), option("build", None, "Build the package before publishing."),
] ]
...@@ -57,6 +68,15 @@ the config command. ...@@ -57,6 +68,15 @@ the config command.
self.line("") self.line("")
cert = Path(self.option("cert")) if self.option("cert") else None
client_cert = (
Path(self.option("client-cert")) if self.option("client-cert") else None
)
publisher.publish( publisher.publish(
self.option("repository"), self.option("username"), self.option("password") self.option("repository"),
self.option("username"),
self.option("password"),
cert,
client_cert,
) )
...@@ -233,7 +233,7 @@ class Factory: ...@@ -233,7 +233,7 @@ class Factory:
): # type: (Dict[str, str], Config) -> LegacyRepository ): # type: (Dict[str, str], Config) -> LegacyRepository
from .repositories.auth import Auth from .repositories.auth import Auth
from .repositories.legacy_repository import LegacyRepository from .repositories.legacy_repository import LegacyRepository
from .utils.helpers import get_http_basic_auth from .utils.helpers import get_client_cert, get_cert, get_http_basic_auth
if "url" in source: if "url" in source:
# PyPI-like repository # PyPI-like repository
...@@ -245,12 +245,18 @@ class Factory: ...@@ -245,12 +245,18 @@ class Factory:
name = source["name"] name = source["name"]
url = source["url"] url = source["url"]
credentials = get_http_basic_auth(auth_config, name) credentials = get_http_basic_auth(auth_config, name)
if not credentials: if credentials:
return LegacyRepository(name, url) auth = Auth(url, credentials[0], credentials[1])
else:
auth = Auth(url, credentials[0], credentials[1]) auth = None
return LegacyRepository(name, url, auth=auth) return LegacyRepository(
name,
url,
auth=auth,
cert=get_cert(auth_config, name),
client_cert=get_client_cert(auth_config, name),
)
@classmethod @classmethod
def validate( def validate(
......
...@@ -52,6 +52,12 @@ class PipInstaller(BaseInstaller): ...@@ -52,6 +52,12 @@ class PipInstaller(BaseInstaller):
) )
args += ["--trusted-host", parsed.hostname] args += ["--trusted-host", parsed.hostname]
if repository.cert:
args += ["--cert", str(repository.cert)]
if repository.client_cert:
args += ["--client-cert", str(repository.client_cert)]
index_url = repository.authenticated_url index_url = repository.authenticated_url
args += ["--index-url", index_url] args += ["--index-url", index_url]
......
import logging import logging
from poetry.utils.helpers import get_http_basic_auth from poetry.utils.helpers import get_client_cert, get_cert, get_http_basic_auth
from .uploader import Uploader from .uploader import Uploader
...@@ -23,7 +23,7 @@ class Publisher: ...@@ -23,7 +23,7 @@ class Publisher:
def files(self): def files(self):
return self._uploader.files return self._uploader.files
def publish(self, repository_name, username, password): def publish(self, repository_name, username, password, cert=None, client_cert=None):
if repository_name: if repository_name:
self._io.write_line( self._io.write_line(
"Publishing <info>{}</info> (<comment>{}</comment>) " "Publishing <info>{}</info> (<comment>{}</comment>) "
...@@ -74,15 +74,21 @@ class Publisher: ...@@ -74,15 +74,21 @@ class Publisher:
username = auth[0] username = auth[0]
password = auth[1] password = auth[1]
# Requesting missing credentials resolved_client_cert = client_cert or get_client_cert(
if not username: self._poetry.config, repository_name
username = self._io.ask("Username:") )
# Requesting missing credentials but only if there is not a client cert defined.
if not resolved_client_cert:
if username is None:
username = self._io.ask("Username:")
if password is None: if password is None:
password = self._io.ask_hidden("Password:") password = self._io.ask_hidden("Password:")
# TODO: handle certificates
self._uploader.auth(username, password) self._uploader.auth(username, password)
return self._uploader.upload(url) return self._uploader.upload(
url,
cert=cert or get_cert(self._poetry.config, repository_name),
client_cert=resolved_client_cert,
)
...@@ -3,7 +3,7 @@ import io ...@@ -3,7 +3,7 @@ import io
import math import math
import re import re
from typing import List from typing import List, Optional
import requests import requests
...@@ -14,6 +14,7 @@ from requests_toolbelt import user_agent ...@@ -14,6 +14,7 @@ from requests_toolbelt import user_agent
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
from poetry.__version__ import __version__ from poetry.__version__ import __version__
from poetry.utils._compat import Path
from poetry.utils.helpers import normalize_version from poetry.utils.helpers import normalize_version
from poetry.utils.patterns import wheel_file_re from poetry.utils.patterns import wheel_file_re
...@@ -92,9 +93,17 @@ class Uploader: ...@@ -92,9 +93,17 @@ class Uploader:
def is_authenticated(self): def is_authenticated(self):
return self._username is not None and self._password is not None return self._username is not None and self._password is not None
def upload(self, url): def upload(
self, url, cert=None, client_cert=None
): # type: (str, Optional[Path], Optional[Path]) -> None
session = self.make_session() session = self.make_session()
if cert:
session.verify = str(cert)
if client_cert:
session.cert = str(client_cert)
try: try:
self._upload(session, url) self._upload(session, url)
finally: finally:
......
...@@ -160,8 +160,8 @@ class Page: ...@@ -160,8 +160,8 @@ class Page:
class LegacyRepository(PyPiRepository): class LegacyRepository(PyPiRepository):
def __init__( def __init__(
self, name, url, auth=None, disable_cache=False self, name, url, auth=None, disable_cache=False, cert=None, client_cert=None
): # type: (str, str, Optional[Auth], bool) -> None ): # type: (str, str, Optional[Auth], bool, Optional[Path], Optional[Path]) -> None
if name == "pypi": if name == "pypi":
raise ValueError("The name [pypi] is reserved for repositories") raise ValueError("The name [pypi] is reserved for repositories")
...@@ -169,6 +169,8 @@ class LegacyRepository(PyPiRepository): ...@@ -169,6 +169,8 @@ class LegacyRepository(PyPiRepository):
self._name = name self._name = name
self._url = url.rstrip("/") self._url = url.rstrip("/")
self._auth = auth self._auth = auth
self._client_cert = client_cert
self._cert = cert
self._inspector = Inspector() self._inspector = Inspector()
self._cache_dir = Path(CACHE_DIR) / "cache" / "repositories" / name self._cache_dir = Path(CACHE_DIR) / "cache" / "repositories" / name
self._cache = CacheManager( self._cache = CacheManager(
...@@ -191,9 +193,23 @@ class LegacyRepository(PyPiRepository): ...@@ -191,9 +193,23 @@ class LegacyRepository(PyPiRepository):
if not url_parts.username and self._auth: if not url_parts.username and self._auth:
self._session.auth = self._auth self._session.auth = self._auth
if self._cert:
self._session.verify = str(self._cert)
if self._client_cert:
self._session.cert = str(self._client_cert)
self._disable_cache = disable_cache self._disable_cache = disable_cache
@property @property
def cert(self): # type: () -> Optional[Path]
return self._cert
@property
def client_cert(self): # type: () -> Optional[Path]
return self._client_cert
@property
def authenticated_url(self): # type: () -> str def authenticated_url(self): # type: () -> str
if not self._auth: if not self._auth:
return self.url return self.url
......
...@@ -18,6 +18,7 @@ from keyring.errors import KeyringError ...@@ -18,6 +18,7 @@ from keyring.errors import KeyringError
from poetry.config.config import Config from poetry.config.config import Config
from poetry.version import Version from poetry.version import Version
from poetry.utils._compat import Path
_canonicalize_regex = re.compile("[-_]+") _canonicalize_regex = re.compile("[-_]+")
...@@ -137,6 +138,22 @@ def get_http_basic_auth( ...@@ -137,6 +138,22 @@ def get_http_basic_auth(
return None return None
def get_cert(config, repository_name): # type: (Config, str) -> Optional[Path]
cert = config.get("certificates.{}.cert".format(repository_name))
if cert:
return Path(cert)
else:
return None
def get_client_cert(config, repository_name): # type: (Config, str) -> Optional[Path]
client_cert = config.get("certificates.{}.client-cert".format(repository_name))
if client_cert:
return Path(client_cert)
else:
return None
def _on_rm_error(func, path, exc_info): def _on_rm_error(func, path, exc_info):
os.chmod(path, stat.S_IWRITE) os.chmod(path, stat.S_IWRITE)
func(path) func(path)
......
...@@ -99,3 +99,26 @@ def test_set_pypi_token(app, config, config_source, auth_config_source): ...@@ -99,3 +99,26 @@ def test_set_pypi_token(app, config, config_source, auth_config_source):
tester.execute("--list") tester.execute("--list")
assert "mytoken" == auth_config_source.config["pypi-token"]["pypi"] assert "mytoken" == auth_config_source.config["pypi-token"]["pypi"]
def test_set_client_cert(app, config_source, auth_config_source, mocker):
init = mocker.spy(ConfigSource, "__init__")
command = app.find("config")
tester = CommandTester(command)
tester.execute("certificates.foo.client-cert path/to/cert.pem")
assert (
"path/to/cert.pem"
== auth_config_source.config["certificates"]["foo"]["client-cert"]
)
def test_set_cert(app, config_source, auth_config_source, mocker):
init = mocker.spy(ConfigSource, "__init__")
command = app.find("config")
tester = CommandTester(command)
tester.execute("certificates.foo.cert path/to/ca.pem")
assert "path/to/ca.pem" == auth_config_source.config["certificates"]["foo"]["cert"]
from poetry.utils._compat import Path
def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http): def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http):
http.register_uri( http.register_uri(
http.POST, "https://upload.pypi.org/legacy/", status=400, body="Bad Request" http.POST, "https://upload.pypi.org/legacy/", status=400, body="Bad Request"
...@@ -16,3 +19,22 @@ HTTP Error 400: Bad Request ...@@ -16,3 +19,22 @@ HTTP Error 400: Bad Request
""" """
assert app_tester.io.fetch_output() == expected assert app_tester.io.fetch_output() == expected
def test_publish_with_cert(app_tester, mocker):
publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish")
app_tester.execute("publish --cert path/to/ca.pem")
assert [
(None, None, None, Path("path/to/ca.pem"), None)
] == publisher_publish.call_args
def test_publish_with_client_cert(app_tester, mocker):
publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish")
app_tester.execute("publish --client-cert path/to/client.pem")
assert [
(None, None, None, None, Path("path/to/client.pem"))
] == publisher_publish.call_args
...@@ -5,6 +5,7 @@ from poetry.io.null_io import NullIO ...@@ -5,6 +5,7 @@ from poetry.io.null_io import NullIO
from poetry.packages.package import Package from poetry.packages.package import Package
from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.legacy_repository import LegacyRepository
from poetry.repositories.pool import Pool from poetry.repositories.pool import Pool
from poetry.utils._compat import Path
from poetry.utils.env import NullEnv from poetry.utils.env import NullEnv
...@@ -89,6 +90,62 @@ def test_install_with_non_pypi_default_repository(pool, installer): ...@@ -89,6 +90,62 @@ def test_install_with_non_pypi_default_repository(pool, installer):
installer.install(bar) installer.install(bar)
def test_install_with_cert():
ca_path = "path/to/cert.pem"
pool = Pool()
default = LegacyRepository("default", "https://foo.bar", cert=Path(ca_path))
pool.add_repository(default, default=True)
null_env = NullEnv()
installer = PipInstaller(null_env, NullIO(), pool)
foo = Package("foo", "0.0.0")
foo.source_type = "legacy"
foo.source_reference = default._name
foo.source_url = default._url
installer.install(foo)
assert len(null_env.executed) == 1
cmd = null_env.executed[0]
assert "--cert" in cmd
cert_index = cmd.index("--cert")
# Need to do the str(Path()) bit because Windows paths get modified by Path
assert cmd[cert_index + 1] == str(Path(ca_path))
def test_install_with_client_cert():
client_path = "path/to/client.pem"
pool = Pool()
default = LegacyRepository(
"default", "https://foo.bar", client_cert=Path(client_path)
)
pool.add_repository(default, default=True)
null_env = NullEnv()
installer = PipInstaller(null_env, NullIO(), pool)
foo = Package("foo", "0.0.0")
foo.source_type = "legacy"
foo.source_reference = default._name
foo.source_url = default._url
installer.install(foo)
assert len(null_env.executed) == 1
cmd = null_env.executed[0]
assert "--client-cert" in cmd
cert_index = cmd.index("--client-cert")
# Need to do the str(Path()) bit because Windows paths get modified by Path
assert cmd[cert_index + 1] == str(Path(client_path))
def test_requirement_git_develop_true(installer, package_git): def test_requirement_git_develop_true(installer, package_git):
package_git.develop = True package_git.develop = True
result = installer.requirement(package_git) result = installer.requirement(package_git)
......
...@@ -3,6 +3,7 @@ import pytest ...@@ -3,6 +3,7 @@ import pytest
from poetry.factory import Factory from poetry.factory import Factory
from poetry.io.null_io import NullIO from poetry.io.null_io import NullIO
from poetry.masonry.publishing.publisher import Publisher from poetry.masonry.publishing.publisher import Publisher
from poetry.utils._compat import Path
def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config):
...@@ -18,7 +19,10 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config): ...@@ -18,7 +19,10 @@ def test_publish_publishes_to_pypi_by_default(fixture_dir, mocker, config):
publisher.publish(None, None, None) publisher.publish(None, None, None)
assert [("foo", "bar")] == uploader_auth.call_args assert [("foo", "bar")] == uploader_auth.call_args
assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args assert [
("https://upload.pypi.org/legacy/",),
{"cert": None, "client_cert": None},
] == uploader_upload.call_args
def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config):
...@@ -37,7 +41,10 @@ def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config): ...@@ -37,7 +41,10 @@ def test_publish_can_publish_to_given_repository(fixture_dir, mocker, config):
publisher.publish("my-repo", None, None) publisher.publish("my-repo", None, None)
assert [("foo", "bar")] == uploader_auth.call_args assert [("foo", "bar")] == uploader_auth.call_args
assert [("http://foo.bar",)] == uploader_upload.call_args assert [
("http://foo.bar",),
{"cert": None, "client_cert": None},
] == uploader_upload.call_args
def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker, config): def test_publish_raises_error_for_undefined_repository(fixture_dir, mocker, config):
...@@ -63,4 +70,52 @@ def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config): ...@@ -63,4 +70,52 @@ def test_publish_uses_token_if_it_exists(fixture_dir, mocker, config):
publisher.publish(None, None, None) publisher.publish(None, None, None)
assert [("__token__", "my-token")] == uploader_auth.call_args assert [("__token__", "my-token")] == uploader_auth.call_args
assert [("https://upload.pypi.org/legacy/",)] == uploader_upload.call_args assert [
("https://upload.pypi.org/legacy/",),
{"cert": None, "client_cert": None},
] == uploader_upload.call_args
def test_publish_uses_cert(fixture_dir, mocker, config):
cert = "path/to/ca.pem"
uploader_auth = mocker.patch("poetry.masonry.publishing.uploader.Uploader.auth")
uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload")
poetry = Factory().create_poetry(fixture_dir("sample_project"))
poetry._config = config
poetry.config.merge(
{
"repositories": {"foo": {"url": "https://foo.bar"}},
"http-basic": {"foo": {"username": "foo", "password": "bar"}},
"certificates": {"foo": {"cert": cert}},
}
)
publisher = Publisher(poetry, NullIO())
publisher.publish("foo", None, None)
assert [("foo", "bar")] == uploader_auth.call_args
assert [
("https://foo.bar",),
{"cert": Path(cert), "client_cert": None},
] == uploader_upload.call_args
def test_publish_uses_client_cert(fixture_dir, mocker, config):
client_cert = "path/to/client.pem"
uploader_upload = mocker.patch("poetry.masonry.publishing.uploader.Uploader.upload")
poetry = Factory().create_poetry(fixture_dir("sample_project"))
poetry._config = config
poetry.config.merge(
{
"repositories": {"foo": {"url": "https://foo.bar"}},
"certificates": {"foo": {"client-cert": client_cert}},
}
)
publisher = Publisher(poetry, NullIO())
publisher.publish("foo", None, None)
assert [
("https://foo.bar",),
{"cert": None, "client_cert": Path(client_cert)},
] == uploader_upload.call_args
from poetry.utils.helpers import get_http_basic_auth from poetry.utils._compat import Path
from poetry.utils.helpers import get_client_cert, get_cert, get_http_basic_auth
from poetry.utils.helpers import parse_requires from poetry.utils.helpers import parse_requires
...@@ -65,3 +66,17 @@ def test_get_http_basic_auth_without_password(config): ...@@ -65,3 +66,17 @@ def test_get_http_basic_auth_without_password(config):
def test_get_http_basic_auth_missing(config): def test_get_http_basic_auth_missing(config):
assert get_http_basic_auth(config, "foo") is None assert get_http_basic_auth(config, "foo") is None
def test_get_cert(config):
ca_cert = "path/to/ca.pem"
config.merge({"certificates": {"foo": {"cert": ca_cert}}})
assert get_cert(config, "foo") == Path(ca_cert)
def test_get_client_cert(config):
client_cert = "path/to/client.pem"
config.merge({"certificates": {"foo": {"client-cert": client_cert}}})
assert get_client_cert(config, "foo") == Path(client_cert)
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