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 support for custom certificate authority and client certificates #1325

Merged
merged 9 commits into from
Oct 22, 2019
16 changes: 14 additions & 2 deletions docs/docs/repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ export POETRY_HTTP_BASIC_PYPI_PASSWORD=password
See [Using environment variables](/configuration#using-environment-variables) for more information
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.custom-ca /path/to/ca.pem
poetry config certificates.foo.client-cert /path/to/client.pem
```

### Install dependencies from a private repository

Now that you can publish to your private repository, you need to be able to
Expand Down Expand Up @@ -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
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
to authenticate to your private repository when downloading or looking for packages.
same name that is in the `tool.poetry.source` section). If your repository requires either
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
Expand Down
21 changes: 21 additions & 0 deletions poetry/console/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,27 @@ def handle(self):

return 0

# handle certs
m = re.match(
r"(?:certificates)\.([^.]+)\.(custom-ca|client-cert)", self.argument("key")
)
if m:
if self.option("unset"):
auth_config_source.remove_property(
"certificates.{}.{}".format(m.group(1), m.group(2))
)

return 0

if len(values) == 1:
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")))

def _handle_single_value(self, source, key, callbacks, values):
Expand Down
25 changes: 24 additions & 1 deletion poetry/console/commands/publish.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from cleo import option

from poetry.utils._compat import Path

from .command import Command


Expand All @@ -14,6 +16,18 @@ class PublishCommand(Command):
),
option("username", "u", "The username to access the repository.", flag=False),
option("password", "p", "The password to access the repository.", flag=False),
option(
"custom-ca",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor -- consider using curl or twine's syntax

--cacert, --cert, --key

or

--cert, --client-cert with client-cert being a combined pem

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably struggled the most with naming the silly parameters compared to anything else about this PR. I started with custom-ca, hated it, but then figured we could come to consensus in the PR itself :)

Since I'm interfacing with both requests and pip under the hood, I need to use a combined client cert/key file as that's the only thing both take (pip is the picky one). That being said, I liked pip's --client-cert and went with it.

For CA:

  • Twine (as you noted) uses --cacert
  • pip (as you noted) uses --cert
  • cURL uses --cacert
  • wget uses --ca-certificate
  • requests uses the parameter verify (that's terrible)
  • Python's built-in ssl module uses the parameter cafile
  • urllib3 uses the parameter ca_certs

Since I did --client-cert (with the hyphen in between), I'm thinking it should be consistent and be --ca-<SOMETHING> but that looks weird too. I'm happy to change it to anything that the maintainers will accept; I fully recognize --custom-ca is not great.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can follow what's already used in both pip and twine, i.e. --cert and --client-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."),
]

Expand Down Expand Up @@ -57,6 +71,15 @@ def handle(self):

self.line("")

custom_ca = Path(self.option("custom-ca")) if self.option("custom-ca") else None
client_cert = (
Path(self.option("client-cert")) if self.option("client-cert") else None
)

publisher.publish(
self.option("repository"), self.option("username"), self.option("password")
self.option("repository"),
self.option("username"),
self.option("password"),
custom_ca,
client_cert,
)
6 changes: 6 additions & 0 deletions poetry/installation/pip_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ def install(self, package, update=False):
)
args += ["--trusted-host", parsed.hostname]

if repository.custom_ca:
args += ["--cert", str(repository.custom_ca)]

if repository.client_cert:
args += ["--client-cert", str(repository.client_cert)]

index_url = repository.authenticated_url

args += ["--index-url", index_url]
Expand Down
28 changes: 18 additions & 10 deletions poetry/masonry/publishing/publisher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging

from poetry.utils.helpers import get_http_basic_auth
from poetry.utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth

from .uploader import Uploader

Expand All @@ -23,7 +23,9 @@ def __init__(self, poetry, io):
def files(self):
return self._uploader.files

def publish(self, repository_name, username, password):
def publish(
self, repository_name, username, password, custom_ca=None, client_cert=None
):
if repository_name:
self._io.write_line(
"Publishing <info>{}</info> (<comment>{}</comment>) "
Expand Down Expand Up @@ -74,15 +76,21 @@ def publish(self, repository_name, username, password):
username = auth[0]
password = auth[1]

# Requesting missing credentials
if not username:
username = self._io.ask("Username:")
resolved_client_cert = client_cert or get_client_cert(
self._poetry.config, repository_name
)
# 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:
password = self._io.ask_hidden("Password:")

# TODO: handle certificates
if password is None:
password = self._io.ask_hidden("Password:")

self._uploader.auth(username, password)

return self._uploader.upload(url)
return self._uploader.upload(
url,
custom_ca=custom_ca or get_custom_ca(self._poetry.config, repository_name),
client_cert=resolved_client_cert,
)
13 changes: 11 additions & 2 deletions poetry/masonry/publishing/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import math
import re

from typing import List
from typing import List, Optional

import requests

Expand All @@ -14,6 +14,7 @@
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor

from poetry.__version__ import __version__
from poetry.utils._compat import Path
from poetry.utils.helpers import normalize_version
from poetry.utils.patterns import wheel_file_re

Expand Down Expand Up @@ -94,9 +95,17 @@ def make_session(self):
def is_authenticated(self):
return self._username is not None and self._password is not None

def upload(self, url):
def upload(
self, url, custom_ca=None, client_cert=None
): # type: (str, Optional[Path], Optional[Path]) -> None
session = self.make_session()

if custom_ca:
session.verify = str(custom_ca)

if client_cert:
session.cert = str(client_cert)

try:
self._upload(session, url)
finally:
Expand Down
20 changes: 13 additions & 7 deletions poetry/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .repositories.pypi_repository import PyPiRepository
from .spdx import license_by_id
from .utils._compat import Path
from .utils.helpers import get_http_basic_auth
from .utils.helpers import get_client_cert, get_custom_ca, get_http_basic_auth
from .utils.toml_file import TomlFile


Expand Down Expand Up @@ -227,12 +227,18 @@ def create_legacy_repository(
name = source["name"]
url = source["url"]
credentials = get_http_basic_auth(self._config, name)
if not credentials:
return LegacyRepository(name, url)

auth = Auth(url, credentials[0], credentials[1])

return LegacyRepository(name, url, auth=auth)
if credentials:
auth = Auth(url, credentials[0], credentials[1])
else:
auth = None

return LegacyRepository(
name,
url,
auth=auth,
custom_ca=get_custom_ca(self._config, name),
client_cert=get_client_cert(self._config, name),
)

@classmethod
def locate(cls, cwd): # type: (Path) -> Poetry
Expand Down
26 changes: 24 additions & 2 deletions poetry/repositories/legacy_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,23 @@ def clean_link(self, url):

class LegacyRepository(PyPiRepository):
def __init__(
self, name, url, auth=None, disable_cache=False
): # type: (str, str, Optional[Auth], bool) -> None
self,
name,
url,
auth=None,
disable_cache=False,
custom_ca=None,
client_cert=None,
): # type: (str, str, Optional[Auth], bool, Optional[Path], Optional[Path]) -> None
if name == "pypi":
raise ValueError("The name [pypi] is reserved for repositories")

self._packages = []
self._name = name
self._url = url.rstrip("/")
self._auth = auth
self._client_cert = client_cert
self._custom_ca = custom_ca
self._inspector = Inspector()
self._cache_dir = Path(CACHE_DIR) / "cache" / "repositories" / name
self._cache = CacheManager(
Expand All @@ -186,8 +194,22 @@ def __init__(
if not url_parts.username and self._auth:
self._session.auth = self._auth

if self._custom_ca:
self._session.verify = str(self._custom_ca)

if self._client_cert:
self._session.cert = str(self._client_cert)

self._disable_cache = disable_cache

@property
def custom_ca(self): # type: () -> Optional[Path]
return self._custom_ca

@property
def client_cert(self): # type: () -> Optional[Path]
return self._client_cert

@property
def authenticated_url(self): # type: () -> str
if not self._auth:
Expand Down
17 changes: 17 additions & 0 deletions poetry/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from poetry.config.config import Config
from poetry.version import Version
from poetry.utils._compat import Path

_canonicalize_regex = re.compile("[-_]+")

Expand Down Expand Up @@ -133,6 +134,22 @@ def get_http_basic_auth(
return None


def get_custom_ca(config, repository_name): # type: (Config, str) -> Optional[Path]
custom_ca = config.get("certificates.{}.custom-ca".format(repository_name))
if custom_ca:
return Path(custom_ca)
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):
os.chmod(path, stat.S_IWRITE)
func(path)
Expand Down
20 changes: 20 additions & 0 deletions tests/console/commands/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,23 @@ def test_set_pypi_token(app, config_source, config_document, mocker):
tester.execute("--list")

assert "mytoken" == config_document["pypi-token"]["pypi"]


def test_set_client_cert(app, config_source, config_document, 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" == config_document["certificates"]["foo"]["client-cert"]


def test_set_custom_ca(app, config_source, config_document, mocker):
init = mocker.spy(ConfigSource, "__init__")
command = app.find("config")
tester = CommandTester(command)

tester.execute("certificates.foo.custom-ca path/to/ca.pem")

assert "path/to/ca.pem" == config_document["certificates"]["foo"]["custom-ca"]
22 changes: 22 additions & 0 deletions tests/console/commands/test_publish.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from poetry.utils._compat import Path


def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http):
http.register_uri(
http.POST, "https://upload.pypi.org/legacy/", status=400, body="Bad Request"
Expand All @@ -16,3 +19,22 @@ def test_publish_returns_non_zero_code_for_upload_errors(app, app_tester, http):
"""

assert app_tester.io.fetch_output() == expected


def test_publish_with_custom_ca(app_tester, mocker):
publisher_publish = mocker.patch("poetry.masonry.publishing.Publisher.publish")

app_tester.execute("publish --custom-ca 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
Loading