Skip to content

Commit 1ca4cbd

Browse files
committed
Only query the keyring for URLs that actually trigger error 401
Fixes #8090
1 parent 7369ac2 commit 1ca4cbd

File tree

4 files changed

+79
-7
lines changed

4 files changed

+79
-7
lines changed

news/8090.bugfix

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Only query the keyring for URLs that actually trigger error 401.
2+
This prevents an unnecessary keyring unlock prompt on every pip install
3+
invocation (even with default index URL which is not password protected).

src/pip/_internal/network/auth.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def _get_index_url(self, url):
112112
return None
113113

114114
def _get_new_credentials(self, original_url, allow_netrc=True,
115-
allow_keyring=True):
115+
allow_keyring=False):
116116
# type: (str, bool, bool) -> AuthInfo
117117
"""Find and return credentials for the specified URL."""
118118
# Split the credentials and netloc from the url.
@@ -252,8 +252,15 @@ def handle_401(self, resp, **kwargs):
252252

253253
parsed = urllib.parse.urlparse(resp.url)
254254

255+
# Query the keyring for credentials:
256+
username, password = self._get_new_credentials(resp.url,
257+
allow_netrc=False,
258+
allow_keyring=True)
259+
255260
# Prompt the user for a new username and password
256-
username, password, save = self._prompt_for_password(parsed.netloc)
261+
save = False
262+
if not username or not password:
263+
username, password, save = self._prompt_for_password(parsed.netloc)
257264

258265
# Store the new username and password to use for future requests
259266
self._credentials_to_save = None

tests/functional/test_install_config.py

+49
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,52 @@ def test_do_not_prompt_for_authentication(script, data, cert_factory):
274274
'--no-input', 'simple', expect_error=True)
275275

276276
assert "ERROR: HTTP error 401" in result.stderr
277+
278+
279+
@pytest.mark.parametrize("auth_needed", (True, False))
280+
def test_prompt_for_keyring_if_needed(script, data, cert_factory, auth_needed):
281+
"""Test behaviour while installing from a index url
282+
requiring authentication and keyring is possible.
283+
"""
284+
cert_path = cert_factory()
285+
ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
286+
ctx.load_cert_chain(cert_path, cert_path)
287+
ctx.load_verify_locations(cafile=cert_path)
288+
ctx.verify_mode = ssl.CERT_REQUIRED
289+
290+
response = authorization_response if auth_needed else file_response
291+
292+
server = make_mock_server(ssl_context=ctx)
293+
server.mock.side_effect = [
294+
package_page({
295+
"simple-3.0.tar.gz": "/files/simple-3.0.tar.gz",
296+
}),
297+
response(str(data.packages / "simple-3.0.tar.gz")),
298+
response(str(data.packages / "simple-3.0.tar.gz")),
299+
]
300+
301+
url = "https://{}:{}/simple".format(server.host, server.port)
302+
303+
keyring_content = textwrap.dedent("""\
304+
import os
305+
import sys
306+
from collections import namedtuple
307+
308+
Cred = namedtuple("Cred", ["username", "password"])
309+
310+
def get_credential(url, username):
311+
sys.stderr.write("get_credential was called" + os.linesep)
312+
return Cred("USERNAME", "PASSWORD")
313+
""")
314+
keyring_path = script.site_packages_path / 'keyring.py'
315+
keyring_path.write_text(keyring_content)
316+
317+
with server_running(server):
318+
result = script.pip('install', "--index-url", url,
319+
"--cert", cert_path, "--client-cert", cert_path,
320+
'simple')
321+
322+
if auth_needed:
323+
assert "get_credential was called" in result.stderr
324+
else:
325+
assert "get_credential was called" not in result.stderr

tests/lib/server.py

+18-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import signal
33
import ssl
44
import threading
5+
from base64 import b64encode
56
from contextlib import contextmanager
67
from textwrap import dedent
78

@@ -219,14 +220,26 @@ def responder(environ, start_response):
219220

220221

221222
def authorization_response(path):
223+
# type: (str) -> Responder
224+
correct_auth = "Basic " + b64encode(b"USERNAME:PASSWORD").decode("ascii")
225+
222226
def responder(environ, start_response):
223227
# type: (Environ, StartResponse) -> Body
224228

225-
start_response(
226-
"401 Unauthorized", [
227-
("WWW-Authenticate", "Basic"),
228-
],
229-
)
229+
if environ.get('HTTP_AUTHORIZATION') == correct_auth:
230+
size = os.stat(path).st_size
231+
start_response(
232+
"200 OK", [
233+
("Content-Type", "application/octet-stream"),
234+
("Content-Length", str(size)),
235+
],
236+
)
237+
else:
238+
start_response(
239+
"401 Unauthorized", [
240+
("WWW-Authenticate", "Basic"),
241+
],
242+
)
230243

231244
with open(path, 'rb') as f:
232245
return [f.read()]

0 commit comments

Comments
 (0)