Skip to content

Commit 6ddd9ed

Browse files
committed
Only query the keyring for URLs that actually trigger error 401
Fixes #8090
1 parent 134b7fa commit 6ddd9ed

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
@@ -1,3 +1,6 @@
11
Only attempt to use the keyring once and if it fails, don't try again.
22
This prevents spamming users with several keyring unlock prompts when they
33
cannot unlock or don't want to do so.
4+
Only query the keyring for URLs that actually trigger error 401.
5+
This prevents an unnecessary keyring unlock prompt on every pip install
6+
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

@@ -218,14 +219,26 @@ def responder(environ, start_response):
218219

219220

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

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

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

0 commit comments

Comments
 (0)