Commit b1422f1f by Sébastien Eustace Committed by GitHub

Merge pull request #2990 from abn/fix-http-auth

Multiple fixes for repository http authentication
parents 5b94aa8e c8d6fbce
...@@ -49,6 +49,16 @@ class Factory(BaseFactory): ...@@ -49,6 +49,16 @@ class Factory(BaseFactory):
config.merge(local_config_file.read()) config.merge(local_config_file.read())
# Load local sources
repositories = {}
for source in base_poetry.pyproject.poetry_config.get("source", []):
name = source.get("name")
url = source.get("url")
if name and url:
repositories[name] = {"url": url}
config.merge({"repositories": repositories})
poetry = Poetry( poetry = Poetry(
base_poetry.file.path, base_poetry.file.path,
base_poetry.local_config, base_poetry.local_config,
...@@ -124,11 +134,9 @@ class Factory(BaseFactory): ...@@ -124,11 +134,9 @@ class Factory(BaseFactory):
def create_legacy_repository( def create_legacy_repository(
self, source, auth_config self, source, auth_config
): # type: (Dict[str, str], Config) -> LegacyRepository ): # type: (Dict[str, str], Config) -> LegacyRepository
from .repositories.auth import Auth
from .repositories.legacy_repository import LegacyRepository from .repositories.legacy_repository import LegacyRepository
from .utils.helpers import get_cert from .utils.helpers import get_cert
from .utils.helpers import get_client_cert from .utils.helpers import get_client_cert
from .utils.password_manager import PasswordManager
if "url" in source: if "url" in source:
# PyPI-like repository # PyPI-like repository
...@@ -137,19 +145,13 @@ class Factory(BaseFactory): ...@@ -137,19 +145,13 @@ class Factory(BaseFactory):
else: else:
raise RuntimeError("Unsupported source specified") raise RuntimeError("Unsupported source specified")
password_manager = PasswordManager(auth_config)
name = source["name"] name = source["name"]
url = source["url"] url = source["url"]
credentials = password_manager.get_http_auth(name)
if credentials:
auth = Auth(url, credentials["username"], credentials["password"])
else:
auth = None
return LegacyRepository( return LegacyRepository(
name, name,
url, url,
auth=auth, config=auth_config,
cert=get_cert(auth_config, name), cert=get_cert(auth_config, name),
client_cert=get_client_cert(auth_config, name), client_cert=get_client_cert(auth_config, name),
) )
import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
...@@ -21,14 +22,27 @@ if TYPE_CHECKING: ...@@ -21,14 +22,27 @@ if TYPE_CHECKING:
from poetry.config.config import Config from poetry.config.config import Config
logger = logging.getLogger()
class Authenticator(object): class Authenticator(object):
def __init__(self, config, io): # type: (Config, IO) -> None def __init__(self, config, io=None): # type: (Config, Optional[IO]) -> None
self._config = config self._config = config
self._io = io self._io = io
self._session = None self._session = None
self._credentials = {} self._credentials = {}
self._password_manager = PasswordManager(self._config) self._password_manager = PasswordManager(self._config)
def _log(self, message, level="debug"): # type: (str, str) -> None
if self._io is not None:
self._io.write_line(
"<{level:s}>{message:s}</{level:s}>".format(
message=message, level=level
)
)
else:
getattr(logger, level, logger.debug)(message)
@property @property
def session(self): # type: () -> requests.Session def session(self): # type: () -> requests.Session
if self._session is None: if self._session is None:
...@@ -40,9 +54,7 @@ class Authenticator(object): ...@@ -40,9 +54,7 @@ class Authenticator(object):
self, method, url, **kwargs self, method, url, **kwargs
): # type: (str, str, Any) -> requests.Response ): # type: (str, str, Any) -> requests.Response
request = requests.Request(method, url) request = requests.Request(method, url)
io = kwargs.get("io") or self._io username, password = self.get_credentials_for_url(url)
username, password = self._get_credentials_for_url(url)
if username is not None and password is not None: if username is not None and password is not None:
request = requests.auth.HTTPBasicAuth(username, password)(request) request = requests.auth.HTTPBasicAuth(username, password)(request)
...@@ -83,19 +95,16 @@ class Authenticator(object): ...@@ -83,19 +95,16 @@ class Authenticator(object):
if not is_last_attempt: if not is_last_attempt:
attempt += 1 attempt += 1
delay = 0.5 * attempt delay = 0.5 * attempt
if io is not None: self._log(
io.write_line( "Retrying HTTP request in {} seconds.".format(delay), level="debug"
"<debug>Retrying HTTP request in {} seconds.</debug>".format( )
delay
)
)
time.sleep(delay) time.sleep(delay)
continue continue
# this should never really be hit under any sane circumstance # this should never really be hit under any sane circumstance
raise PoetryException("Failed HTTP {} request", method.upper()) raise PoetryException("Failed HTTP {} request", method.upper())
def _get_credentials_for_url( def get_credentials_for_url(
self, url self, url
): # type: (str) -> Tuple[Optional[str], Optional[str]] ): # type: (str) -> Tuple[Optional[str], Optional[str]]
parsed_url = urlparse.urlsplit(url) parsed_url = urlparse.urlsplit(url)
...@@ -135,7 +144,8 @@ class Authenticator(object): ...@@ -135,7 +144,8 @@ class Authenticator(object):
self, netloc self, netloc
): # type: (str) -> Tuple[Optional[str], Optional[str]] ): # type: (str) -> Tuple[Optional[str], Optional[str]]
credentials = (None, None) credentials = (None, None)
for repository_name in self._config.get("http-basic", {}):
for repository_name in self._config.get("repositories", []):
repository_config = self._config.get( repository_config = self._config.get(
"repositories.{}".format(repository_name) "repositories.{}".format(repository_name)
) )
......
from requests import Request
from requests.auth import AuthBase
from requests.auth import HTTPBasicAuth
from poetry.utils._compat import urlparse
class Auth(AuthBase):
def __init__(self, url, username, password): # type: (str, str, str) -> None
self._hostname = urlparse.urlparse(url).hostname
self._auth = HTTPBasicAuth(username, password)
@property
def hostname(self): # type: () -> str
return self._hostname
@property
def auth(self): # type: () -> HTTPBasicAuth
return self._auth
def __call__(self, r): # type: (Request) -> Request
if urlparse.urlparse(r.url).hostname != self._hostname:
return r
self._auth(r)
return r
...@@ -8,6 +8,7 @@ from typing import Optional ...@@ -8,6 +8,7 @@ from typing import Optional
from typing import Union from typing import Union
import requests import requests
import requests.auth
from cachecontrol import CacheControl from cachecontrol import CacheControl
from cachecontrol.caches.file_cache import FileCache from cachecontrol.caches.file_cache import FileCache
...@@ -24,8 +25,9 @@ from poetry.utils._compat import Path ...@@ -24,8 +25,9 @@ from poetry.utils._compat import Path
from poetry.utils.helpers import canonicalize_name from poetry.utils.helpers import canonicalize_name
from poetry.utils.patterns import wheel_file_re from poetry.utils.patterns import wheel_file_re
from ..config.config import Config
from ..inspection.info import PackageInfo from ..inspection.info import PackageInfo
from .auth import Auth from ..installation.authenticator import Authenticator
from .exceptions import PackageNotFound from .exceptions import PackageNotFound
from .exceptions import RepositoryError from .exceptions import RepositoryError
from .pypi_repository import PyPiRepository from .pypi_repository import PyPiRepository
...@@ -159,15 +161,14 @@ class Page: ...@@ -159,15 +161,14 @@ class Page:
class LegacyRepository(PyPiRepository): class LegacyRepository(PyPiRepository):
def __init__( def __init__(
self, name, url, auth=None, disable_cache=False, cert=None, client_cert=None self, name, url, config=None, disable_cache=False, cert=None, client_cert=None
): # type: (str, str, Optional[Auth], bool, Optional[Path], Optional[Path]) -> None ): # type: (str, str, Optional[Config], 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")
self._packages = [] self._packages = []
self._name = name self._name = name
self._url = url.rstrip("/") self._url = url.rstrip("/")
self._auth = auth
self._client_cert = client_cert self._client_cert = client_cert
self._cert = cert self._cert = cert
self._cache_dir = REPOSITORY_CACHE_DIR / name self._cache_dir = REPOSITORY_CACHE_DIR / name
...@@ -183,19 +184,25 @@ class LegacyRepository(PyPiRepository): ...@@ -183,19 +184,25 @@ class LegacyRepository(PyPiRepository):
} }
) )
self._authenticator = Authenticator(
config=config or Config(use_environment=True)
)
self._session = CacheControl( self._session = CacheControl(
requests.session(), cache=FileCache(str(self._cache_dir / "_http")) self._authenticator.session, cache=FileCache(str(self._cache_dir / "_http"))
) )
url_parts = urlparse.urlparse(self._url) username, password = self._authenticator.get_credentials_for_url(self._url)
if not url_parts.username and self._auth: if username is not None and password is not None:
self._session.auth = self._auth self._authenticator.session.auth = requests.auth.HTTPBasicAuth(
username, password
)
if self._cert: if self._cert:
self._session.verify = str(self._cert) self._authenticator.session.verify = str(self._cert)
if self._client_cert: if self._client_cert:
self._session.cert = str(self._client_cert) self._authenticator.session.cert = str(self._client_cert)
self._disable_cache = disable_cache self._disable_cache = disable_cache
...@@ -209,15 +216,15 @@ class LegacyRepository(PyPiRepository): ...@@ -209,15 +216,15 @@ class LegacyRepository(PyPiRepository):
@property @property
def authenticated_url(self): # type: () -> str def authenticated_url(self): # type: () -> str
if not self._auth: if not self._session.auth:
return self.url return self.url
parsed = urlparse.urlparse(self.url) parsed = urlparse.urlparse(self.url)
return "{scheme}://{username}:{password}@{netloc}{path}".format( return "{scheme}://{username}:{password}@{netloc}{path}".format(
scheme=parsed.scheme, scheme=parsed.scheme,
username=quote(self._auth.auth.username, safe=""), username=quote(self._session.auth.username, safe=""),
password=quote(self._auth.auth.password, safe=""), password=quote(self._session.auth.password, safe=""),
netloc=parsed.netloc, netloc=parsed.netloc,
path=parsed.path, path=parsed.path,
) )
......
...@@ -180,3 +180,22 @@ def test_authenticator_request_retries_on_status_code( ...@@ -180,3 +180,22 @@ def test_authenticator_request_retries_on_status_code(
assert excinfo.value.response.text == content assert excinfo.value.response.text == content
assert sleep.call_count == attempts assert sleep.call_count == attempts
@pytest.fixture
def environment_repository_credentials(monkeypatch):
monkeypatch.setenv("POETRY_HTTP_BASIC_FOO_USERNAME", "bar")
monkeypatch.setenv("POETRY_HTTP_BASIC_FOO_PASSWORD", "baz")
def test_authenticator_uses_env_provided_credentials(
config, environ, mock_remote, http, environment_repository_credentials
):
config.merge({"repositories": {"foo": {"url": "https://foo.bar/simple/"}}})
authenticator = Authenticator(config, NullIO())
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
request = http.last_request()
assert "Basic YmFyOmJheg==" == request.headers["Authorization"]
import base64
from requests import Request
from poetry.repositories.auth import Auth
from poetry.utils._compat import decode
from poetry.utils._compat import encode
def test_auth_with_request_on_the_same_host():
auth = Auth("https://python-poetry.org", "foo", "bar")
request = Request("GET", "https://python-poetry.org/docs/")
assert "Authorization" not in request.headers
request = auth(request)
assert "Authorization" in request.headers
assert request.headers["Authorization"] == "Basic {}".format(
decode(base64.b64encode(encode(":".join(("foo", "bar")))))
)
def test_auth_with_request_with_same_authentication():
auth = Auth("https://python-poetry.org", "foo", "bar")
request = Request("GET", "https://foo:bar@python-poetry.org/docs/")
assert "Authorization" not in request.headers
request = auth(request)
assert "Authorization" in request.headers
assert request.headers["Authorization"] == "Basic {}".format(
decode(base64.b64encode(encode(":".join(("foo", "bar")))))
)
def test_auth_with_request_on_different_hosts():
auth = Auth("https://python-poetry.org", "foo", "bar")
request = Request("GET", "https://pendulum.eustace.io/docs/")
assert "Authorization" not in request.headers
request = auth(request)
assert "Authorization" not in request.headers
...@@ -4,7 +4,6 @@ import pytest ...@@ -4,7 +4,6 @@ import pytest
from poetry.core.packages import Dependency from poetry.core.packages import Dependency
from poetry.factory import Factory from poetry.factory import Factory
from poetry.repositories.auth import Auth
from poetry.repositories.exceptions import PackageNotFound from poetry.repositories.exceptions import PackageNotFound
from poetry.repositories.exceptions import RepositoryError from poetry.repositories.exceptions import RepositoryError
from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.legacy_repository import LegacyRepository
...@@ -23,9 +22,9 @@ class MockRepository(LegacyRepository): ...@@ -23,9 +22,9 @@ class MockRepository(LegacyRepository):
FIXTURES = Path(__file__).parent / "fixtures" / "legacy" FIXTURES = Path(__file__).parent / "fixtures" / "legacy"
def __init__(self, auth=None): def __init__(self):
super(MockRepository, self).__init__( super(MockRepository, self).__init__(
"legacy", url="http://legacy.foo.bar", auth=auth, disable_cache=True "legacy", url="http://legacy.foo.bar", disable_cache=True
) )
def _get(self, endpoint): def _get(self, endpoint):
...@@ -302,18 +301,11 @@ def test_get_package_retrieves_packages_with_no_hashes(): ...@@ -302,18 +301,11 @@ def test_get_package_retrieves_packages_with_no_hashes():
assert [] == package.files assert [] == package.files
def test_username_password_special_chars():
auth = Auth("http://legacy.foo.bar", "user:", "/%2Fp@ssword")
repo = MockRepository(auth=auth)
assert "http://user%3A:%2F%252Fp%40ssword@legacy.foo.bar" == repo.authenticated_url
class MockHttpRepository(LegacyRepository): class MockHttpRepository(LegacyRepository):
def __init__(self, endpoint_responses, http): def __init__(self, endpoint_responses, http):
base_url = "http://legacy.foo.bar" base_url = "http://legacy.foo.bar"
super(MockHttpRepository, self).__init__( super(MockHttpRepository, self).__init__(
"legacy", url=base_url, auth=None, disable_cache=True "legacy", url=base_url, disable_cache=True
) )
for endpoint, response in endpoint_responses.items(): for endpoint, response in endpoint_responses.items():
......
...@@ -4,7 +4,6 @@ import pytest ...@@ -4,7 +4,6 @@ import pytest
from poetry.factory import Factory from poetry.factory import Factory
from poetry.packages import Locker as BaseLocker from poetry.packages import Locker as BaseLocker
from poetry.repositories.auth import Auth
from poetry.repositories.legacy_repository import LegacyRepository from poetry.repositories.legacy_repository import LegacyRepository
from poetry.utils._compat import Path from poetry.utils._compat import Path
from poetry.utils.exporter import Exporter from poetry.utils.exporter import Exporter
...@@ -654,11 +653,7 @@ tests/fixtures/distributions/demo-0.1.0.tar.gz; python_version < "3.7" ...@@ -654,11 +653,7 @@ tests/fixtures/distributions/demo-0.1.0.tar.gz; python_version < "3.7"
def test_exporter_exports_requirements_txt_with_legacy_packages(tmp_dir, poetry): def test_exporter_exports_requirements_txt_with_legacy_packages(tmp_dir, poetry):
poetry.pool.add_repository( poetry.pool.add_repository(
LegacyRepository( LegacyRepository("custom", "https://example.com/simple",)
"custom",
"https://example.com/simple",
auth=Auth("https://example.com/simple", "foo", "bar"),
)
) )
poetry.locker.mock_lock_data( poetry.locker.mock_lock_data(
{ {
...@@ -713,11 +708,7 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_and_duplicate_so ...@@ -713,11 +708,7 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_and_duplicate_so
tmp_dir, poetry tmp_dir, poetry
): ):
poetry.pool.add_repository( poetry.pool.add_repository(
LegacyRepository( LegacyRepository("custom", "https://example.com/simple",)
"custom",
"https://example.com/simple",
auth=Auth("https://example.com/simple", "foo", "bar"),
)
) )
poetry.pool.add_repository(LegacyRepository("custom", "https://foobaz.com/simple",)) poetry.pool.add_repository(LegacyRepository("custom", "https://foobaz.com/simple",))
poetry.locker.mock_lock_data( poetry.locker.mock_lock_data(
...@@ -792,12 +783,14 @@ foo==1.2.3 \\ ...@@ -792,12 +783,14 @@ foo==1.2.3 \\
def test_exporter_exports_requirements_txt_with_legacy_packages_and_credentials( def test_exporter_exports_requirements_txt_with_legacy_packages_and_credentials(
tmp_dir, poetry, config tmp_dir, poetry, config
): ):
poetry.config.merge(
{
"repositories": {"custom": {"url": "https://example.com/simple"}},
"http-basic": {"custom": {"username": "foo", "password": "bar"}},
}
)
poetry.pool.add_repository( poetry.pool.add_repository(
LegacyRepository( LegacyRepository("custom", "https://example.com/simple", config=poetry.config)
"custom",
"https://example.com/simple",
auth=Auth("https://example.com/simple", "foo", "bar"),
)
) )
poetry.locker.mock_lock_data( poetry.locker.mock_lock_data(
{ {
......
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