Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pip/_internal/network/auth.py: 27%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

283 statements  

1"""Network Authentication Helpers 

2 

3Contains interface (MultiDomainBasicAuth) and associated glue code for 

4providing credentials in the context of network requests. 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import logging 

11import os 

12import shutil 

13import subprocess 

14import sysconfig 

15import typing 

16import urllib.parse 

17from abc import ABC, abstractmethod 

18from functools import cache 

19from os.path import commonpath 

20from pathlib import Path 

21from typing import Any, NamedTuple 

22 

23from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth 

24from pip._vendor.requests.utils import get_netrc_auth 

25 

26from pip._internal.utils.logging import getLogger 

27from pip._internal.utils.misc import ( 

28 ask, 

29 ask_input, 

30 ask_password, 

31 remove_auth_from_url, 

32 split_auth_netloc_from_url, 

33) 

34from pip._internal.vcs.versioncontrol import AuthInfo 

35 

36if typing.TYPE_CHECKING: 

37 from pip._vendor.requests import PreparedRequest 

38 from pip._vendor.requests.models import Response 

39 

40logger = getLogger(__name__) 

41 

42KEYRING_DISABLED = False 

43 

44 

45class Credentials(NamedTuple): 

46 url: str 

47 username: str 

48 password: str 

49 

50 

51class KeyRingBaseProvider(ABC): 

52 """Keyring base provider interface""" 

53 

54 has_keyring: bool 

55 

56 @abstractmethod 

57 def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: ... 

58 

59 @abstractmethod 

60 def save_auth_info(self, url: str, username: str, password: str) -> None: ... 

61 

62 

63class KeyRingNullProvider(KeyRingBaseProvider): 

64 """Keyring null provider""" 

65 

66 has_keyring = False 

67 

68 def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: 

69 return None 

70 

71 def save_auth_info(self, url: str, username: str, password: str) -> None: 

72 return None 

73 

74 

75class KeyRingPythonProvider(KeyRingBaseProvider): 

76 """Keyring interface which uses locally imported `keyring`""" 

77 

78 has_keyring = True 

79 

80 def __init__(self) -> None: 

81 import keyring 

82 

83 self.keyring = keyring 

84 

85 def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: 

86 # Support keyring's get_credential interface which supports getting 

87 # credentials without a username. This is only available for 

88 # keyring>=15.2.0. 

89 if hasattr(self.keyring, "get_credential"): 

90 logger.debug("Getting credentials from keyring for %s", url) 

91 cred = self.keyring.get_credential(url, username) 

92 if cred is not None: 

93 return cred.username, cred.password 

94 return None 

95 

96 if username is not None: 

97 logger.debug("Getting password from keyring for %s", url) 

98 password = self.keyring.get_password(url, username) 

99 if password: 

100 return username, password 

101 return None 

102 

103 def save_auth_info(self, url: str, username: str, password: str) -> None: 

104 self.keyring.set_password(url, username, password) 

105 

106 

107class KeyRingCliProvider(KeyRingBaseProvider): 

108 """Provider which uses `keyring` cli 

109 

110 Instead of calling the keyring package installed alongside pip 

111 we call keyring on the command line which will enable pip to 

112 use which ever installation of keyring is available first in 

113 PATH. 

114 """ 

115 

116 has_keyring = True 

117 

118 def __init__(self, cmd: str) -> None: 

119 self.keyring = cmd 

120 

121 def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: 

122 return self._get_creds(url, username) 

123 

124 def save_auth_info(self, url: str, username: str, password: str) -> None: 

125 return self._set_password(url, username, password) 

126 

127 def _get_creds(self, service_name: str, username: str | None) -> AuthInfo | None: 

128 """Mirror the implementation of keyring.get_credential using cli""" 

129 if self.keyring is None: 

130 return None 

131 

132 cmd = [self.keyring, "--mode=creds", "--output=json", "get", service_name] 

133 if username is not None: 

134 cmd.append(username) 

135 

136 env = os.environ.copy() 

137 env["PYTHONIOENCODING"] = "utf-8" 

138 res = subprocess.run( # noqa: UP022 

139 cmd, 

140 stdin=subprocess.DEVNULL, 

141 stdout=subprocess.PIPE, 

142 stderr=subprocess.PIPE, 

143 env=env, 

144 ) 

145 

146 # Detect if the user is running an outdated version of keyring without support 

147 # for querying credentials without username 

148 errs = res.stderr.decode("utf-8") 

149 if ( 

150 res.returncode == 2 

151 and "unrecognized arguments" in errs 

152 and "--mode=creds" in errs 

153 ): 

154 raise RuntimeError( 

155 "Keyring util is outdated; must be at least version 25.2.1, " 

156 "please upgrade it" 

157 ) 

158 

159 if res.returncode: 

160 return None 

161 

162 data = json.loads(res.stdout.decode("utf-8")) 

163 return (data["username"], data["password"]) 

164 

165 def _set_password(self, service_name: str, username: str, password: str) -> None: 

166 """Mirror the implementation of keyring.set_password using cli""" 

167 if self.keyring is None: 

168 return None 

169 env = os.environ.copy() 

170 env["PYTHONIOENCODING"] = "utf-8" 

171 subprocess.run( 

172 [self.keyring, "set", service_name, username], 

173 input=f"{password}{os.linesep}".encode(), 

174 env=env, 

175 check=True, 

176 ) 

177 return None 

178 

179 

180@cache 

181def get_keyring_provider(provider: str) -> KeyRingBaseProvider: 

182 logger.verbose("Keyring provider requested: %s", provider) 

183 

184 # keyring has previously failed and been disabled 

185 if KEYRING_DISABLED: 

186 provider = "disabled" 

187 if provider in ["import", "auto"]: 

188 try: 

189 impl = KeyRingPythonProvider() 

190 logger.verbose("Keyring provider set: import") 

191 return impl 

192 except ImportError: 

193 pass 

194 except Exception as exc: 

195 # In the event of an unexpected exception 

196 # we should warn the user 

197 msg = "Installed copy of keyring fails with exception %s" 

198 if provider == "auto": 

199 msg = msg + ", trying to find a keyring executable as a fallback" 

200 logger.warning(msg, exc, exc_info=logger.isEnabledFor(logging.DEBUG)) 

201 if provider in ["subprocess", "auto"]: 

202 cli = shutil.which("keyring") 

203 if cli and cli.startswith(sysconfig.get_path("scripts")): 

204 # all code within this function is stolen from shutil.which implementation 

205 @typing.no_type_check 

206 def PATH_as_shutil_which_determines_it() -> str: 

207 path = os.environ.get("PATH", None) 

208 if path is None: 

209 try: 

210 path = os.confstr("CS_PATH") 

211 except (AttributeError, ValueError): 

212 # os.confstr() or CS_PATH is not available 

213 path = os.defpath 

214 # bpo-35755: Don't use os.defpath if the PATH environment variable is 

215 # set to an empty string 

216 

217 return path 

218 

219 scripts = Path(sysconfig.get_path("scripts")) 

220 

221 paths = [] 

222 for path in PATH_as_shutil_which_determines_it().split(os.pathsep): 

223 p = Path(path) 

224 try: 

225 if not p.samefile(scripts): 

226 paths.append(path) 

227 except FileNotFoundError: 

228 pass 

229 

230 path = os.pathsep.join(paths) 

231 

232 cli = shutil.which("keyring", path=path) 

233 

234 if cli: 

235 logger.verbose("Keyring provider set: subprocess with executable %s", cli) 

236 return KeyRingCliProvider(cli) 

237 

238 logger.verbose("Keyring provider set: disabled") 

239 return KeyRingNullProvider() 

240 

241 

242class MultiDomainBasicAuth(AuthBase): 

243 def __init__( 

244 self, 

245 prompting: bool = True, 

246 index_urls: list[str] | None = None, 

247 keyring_provider: str = "auto", 

248 ) -> None: 

249 self.prompting = prompting 

250 self.index_urls = index_urls 

251 self.keyring_provider = keyring_provider 

252 self.passwords: dict[str, AuthInfo] = {} 

253 # When the user is prompted to enter credentials and keyring is 

254 # available, we will offer to save them. If the user accepts, 

255 # this value is set to the credentials they entered. After the 

256 # request authenticates, the caller should call 

257 # ``save_credentials`` to save these. 

258 self._credentials_to_save: Credentials | None = None 

259 

260 @property 

261 def keyring_provider(self) -> KeyRingBaseProvider: 

262 return get_keyring_provider(self._keyring_provider) 

263 

264 @keyring_provider.setter 

265 def keyring_provider(self, provider: str) -> None: 

266 # The free function get_keyring_provider has been decorated with 

267 # functools.cache. If an exception occurs in get_keyring_auth that 

268 # cache will be cleared and keyring disabled, take that into account 

269 # if you want to remove this indirection. 

270 self._keyring_provider = provider 

271 

272 @property 

273 def use_keyring(self) -> bool: 

274 # We won't use keyring when --no-input is passed unless 

275 # a specific provider is requested because it might require 

276 # user interaction 

277 return self.prompting or self._keyring_provider not in ["auto", "disabled"] 

278 

279 def _get_keyring_auth( 

280 self, 

281 url: str | None, 

282 username: str | None, 

283 ) -> AuthInfo | None: 

284 """Return the tuple auth for a given url from keyring.""" 

285 # Do nothing if no url was provided 

286 if not url: 

287 return None 

288 

289 try: 

290 return self.keyring_provider.get_auth_info(url, username) 

291 except Exception as exc: 

292 # Log the full exception (with stacktrace) at debug, so it'll only 

293 # show up when running in verbose mode. 

294 logger.debug("Keyring is skipped due to an exception", exc_info=True) 

295 # Always log a shortened version of the exception. 

296 logger.warning( 

297 "Keyring is skipped due to an exception: %s", 

298 str(exc), 

299 ) 

300 global KEYRING_DISABLED 

301 KEYRING_DISABLED = True 

302 get_keyring_provider.cache_clear() 

303 return None 

304 

305 def _get_index_url(self, url: str) -> str | None: 

306 """Return the original index URL matching the requested URL. 

307 

308 Cached or dynamically generated credentials may work against 

309 the original index URL rather than just the netloc. 

310 

311 The provided url should have had its username and password 

312 removed already. If the original index url had credentials then 

313 they will be included in the return value. 

314 

315 Returns None if no matching index was found, or if --no-index 

316 was specified by the user. 

317 """ 

318 if not url or not self.index_urls: 

319 return None 

320 

321 url = remove_auth_from_url(url).rstrip("/") + "/" 

322 parsed_url = urllib.parse.urlsplit(url) 

323 

324 candidates = [] 

325 

326 for index in self.index_urls: 

327 index = index.rstrip("/") + "/" 

328 parsed_index = urllib.parse.urlsplit(remove_auth_from_url(index)) 

329 if parsed_url == parsed_index: 

330 return index 

331 

332 if parsed_url.netloc != parsed_index.netloc: 

333 continue 

334 

335 candidate = urllib.parse.urlsplit(index) 

336 candidates.append(candidate) 

337 

338 if not candidates: 

339 return None 

340 

341 candidates.sort( 

342 reverse=True, 

343 key=lambda candidate: len( 

344 commonpath( 

345 [ 

346 parsed_url.path, 

347 candidate.path, 

348 ] 

349 ) 

350 ), 

351 ) 

352 

353 return urllib.parse.urlunsplit(candidates[0]) 

354 

355 def _get_new_credentials( 

356 self, 

357 original_url: str, 

358 *, 

359 allow_netrc: bool = True, 

360 allow_keyring: bool = False, 

361 ) -> AuthInfo: 

362 """Find and return credentials for the specified URL.""" 

363 # Split the credentials and netloc from the url. 

364 url, netloc, url_user_password = split_auth_netloc_from_url( 

365 original_url, 

366 ) 

367 

368 # Start with the credentials embedded in the url 

369 username, password = url_user_password 

370 if username is not None and password is not None: 

371 logger.debug("Found credentials in url for %s", netloc) 

372 return url_user_password 

373 

374 # Find a matching index url for this request 

375 index_url = self._get_index_url(url) 

376 if index_url: 

377 # Split the credentials from the url. 

378 index_info = split_auth_netloc_from_url(index_url) 

379 if index_info: 

380 index_url, _, index_url_user_password = index_info 

381 logger.debug("Found index url %s", index_url) 

382 

383 # If an index URL was found, try its embedded credentials 

384 if index_url and index_url_user_password[0] is not None: 

385 username, password = index_url_user_password 

386 if username is not None and password is not None: 

387 logger.debug("Found credentials in index url for %s", netloc) 

388 return index_url_user_password 

389 

390 # Get creds from netrc if we still don't have them 

391 if allow_netrc: 

392 netrc_auth = get_netrc_auth(original_url) 

393 if netrc_auth: 

394 logger.debug("Found credentials in netrc for %s", netloc) 

395 return netrc_auth 

396 

397 # If we don't have a password and keyring is available, use it. 

398 if allow_keyring: 

399 # The index url is more specific than the netloc, so try it first 

400 # fmt: off 

401 kr_auth = ( 

402 self._get_keyring_auth(index_url, username) or 

403 self._get_keyring_auth(netloc, username) 

404 ) 

405 # fmt: on 

406 if kr_auth: 

407 logger.debug("Found credentials in keyring for %s", netloc) 

408 return kr_auth 

409 

410 return username, password 

411 

412 def _get_url_and_credentials( 

413 self, original_url: str 

414 ) -> tuple[str, str | None, str | None]: 

415 """Return the credentials to use for the provided URL. 

416 

417 If allowed, netrc and keyring may be used to obtain the 

418 correct credentials. 

419 

420 Returns (url_without_credentials, username, password). Note 

421 that even if the original URL contains credentials, this 

422 function may return a different username and password. 

423 """ 

424 url, netloc, _ = split_auth_netloc_from_url(original_url) 

425 

426 # Try to get credentials from original url 

427 username, password = self._get_new_credentials(original_url) 

428 

429 # If credentials not found, use any stored credentials for this netloc. 

430 # Do this if either the username or the password is missing. 

431 # This accounts for the situation in which the user has specified 

432 # the username in the index url, but the password comes from keyring. 

433 if (username is None or password is None) and netloc in self.passwords: 

434 un, pw = self.passwords[netloc] 

435 # It is possible that the cached credentials are for a different username, 

436 # in which case the cache should be ignored. 

437 if username is None or username == un: 

438 username, password = un, pw 

439 

440 if username is not None or password is not None: 

441 # Convert the username and password if they're None, so that 

442 # this netloc will show up as "cached" in the conditional above. 

443 # Further, HTTPBasicAuth doesn't accept None, so it makes sense to 

444 # cache the value that is going to be used. 

445 username = username or "" 

446 password = password or "" 

447 

448 # Store any acquired credentials. 

449 self.passwords[netloc] = (username, password) 

450 

451 assert ( 

452 # Credentials were found 

453 (username is not None and password is not None) 

454 # Credentials were not found 

455 or (username is None and password is None) 

456 ), f"Could not load credentials from url: {original_url}" 

457 

458 return url, username, password 

459 

460 def __call__(self, req: PreparedRequest) -> PreparedRequest: 

461 # Get credentials for this request 

462 assert req.url is not None 

463 url, username, password = self._get_url_and_credentials(req.url) 

464 

465 # Set the url of the request to the url without any credentials 

466 req.url = url 

467 

468 if username is not None and password is not None: 

469 # Send the basic auth with this request 

470 req = HTTPBasicAuth(username, password)(req) 

471 

472 # Attach a hook to handle 401 responses 

473 req.register_hook("response", self.handle_401) 

474 

475 return req 

476 

477 # Factored out to allow for easy patching in tests 

478 def _prompt_for_password(self, netloc: str) -> tuple[str | None, str | None, bool]: 

479 username = ask_input(f"User for {netloc}: ") if self.prompting else None 

480 if not username: 

481 return None, None, False 

482 if self.use_keyring: 

483 auth = self._get_keyring_auth(netloc, username) 

484 if auth and auth[0] is not None and auth[1] is not None: 

485 return auth[0], auth[1], False 

486 password = ask_password("Password: ") 

487 return username, password, True 

488 

489 # Factored out to allow for easy patching in tests 

490 def _should_save_password_to_keyring(self) -> bool: 

491 if ( 

492 not self.prompting 

493 or not self.use_keyring 

494 or not self.keyring_provider.has_keyring 

495 ): 

496 return False 

497 return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y" 

498 

499 def handle_401(self, resp: Response, **kwargs: Any) -> Response: 

500 # We only care about 401 responses, anything else we want to just 

501 # pass through the actual response 

502 if resp.status_code != 401: 

503 return resp 

504 

505 # Look for credentials for the (possibly redirected) URL. Credentials 

506 # embedded in the URL -- e.g. carried in the ``Location`` of a 

507 # cross-origin redirect, whose ``Authorization`` header requests strips 

508 # -- are always honoured, since recovering them needs no user 

509 # interaction. Keyring is only consulted when it is enabled, because it 

510 # may require interaction and is therefore disabled under --no-input. 

511 username, password = self._get_new_credentials( 

512 resp.url, 

513 allow_netrc=False, 

514 allow_keyring=self.use_keyring, 

515 ) 

516 

517 # We are not able to prompt the user so simply return the response 

518 if not self.prompting and not username and not password: 

519 return resp 

520 

521 parsed = urllib.parse.urlparse(resp.url) 

522 

523 # Prompt the user for a new username and password 

524 save = False 

525 if not username and not password: 

526 username, password, save = self._prompt_for_password(parsed.netloc) 

527 

528 # Store the new username and password to use for future requests 

529 self._credentials_to_save = None 

530 if username is not None and password is not None: 

531 self.passwords[parsed.netloc] = (username, password) 

532 

533 # Prompt to save the password to keyring 

534 if save and self._should_save_password_to_keyring(): 

535 self._credentials_to_save = Credentials( 

536 url=parsed.netloc, 

537 username=username, 

538 password=password, 

539 ) 

540 

541 # Consume content and release the original connection to allow our new 

542 # request to reuse the same one. 

543 # The result of the assignment isn't used, it's just needed to consume 

544 # the content. 

545 _ = resp.content 

546 resp.raw.release_conn() 

547 

548 # Add our new username and password to the request 

549 req = HTTPBasicAuth(username or "", password or "")(resp.request) 

550 req.register_hook("response", self.warn_on_401) 

551 

552 # On successful request, save the credentials that were used to 

553 # keyring. (Note that if the user responded "no" above, this member 

554 # is not set and nothing will be saved.) 

555 if self._credentials_to_save: 

556 req.register_hook("response", self.save_credentials) 

557 

558 # Send our new request 

559 new_resp = resp.connection.send(req, **kwargs) 

560 new_resp.history.append(resp) 

561 

562 return new_resp 

563 

564 def warn_on_401(self, resp: Response, **kwargs: Any) -> None: 

565 """Response callback to warn about incorrect credentials.""" 

566 if resp.status_code == 401: 

567 logger.warning( 

568 "401 Error, Credentials not correct for %s", 

569 resp.request.url, 

570 ) 

571 

572 def save_credentials(self, resp: Response, **kwargs: Any) -> None: 

573 """Response callback to save credentials on success.""" 

574 assert ( 

575 self.keyring_provider.has_keyring 

576 ), "should never reach here without keyring" 

577 

578 creds = self._credentials_to_save 

579 self._credentials_to_save = None 

580 if creds and resp.status_code < 400: 

581 try: 

582 logger.info("Saving credentials to keyring") 

583 self.keyring_provider.save_auth_info( 

584 creds.url, creds.username, creds.password 

585 ) 

586 except Exception: 

587 logger.exception("Failed to save credentials")