auth.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. """Network Authentication Helpers
  2. Contains interface (MultiDomainBasicAuth) and associated glue code for
  3. providing credentials in the context of network requests.
  4. """
  5. from __future__ import annotations
  6. import logging
  7. import os
  8. import shutil
  9. import subprocess
  10. import sysconfig
  11. import typing
  12. import urllib.parse
  13. from abc import ABC, abstractmethod
  14. from functools import cache
  15. from os.path import commonprefix
  16. from pathlib import Path
  17. from typing import Any, NamedTuple
  18. from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
  19. from pip._vendor.requests.models import Request, Response
  20. from pip._vendor.requests.utils import get_netrc_auth
  21. from pip._internal.utils.logging import getLogger
  22. from pip._internal.utils.misc import (
  23. ask,
  24. ask_input,
  25. ask_password,
  26. remove_auth_from_url,
  27. split_auth_netloc_from_url,
  28. )
  29. from pip._internal.vcs.versioncontrol import AuthInfo
  30. logger = getLogger(__name__)
  31. KEYRING_DISABLED = False
  32. class Credentials(NamedTuple):
  33. url: str
  34. username: str
  35. password: str
  36. class KeyRingBaseProvider(ABC):
  37. """Keyring base provider interface"""
  38. has_keyring: bool
  39. @abstractmethod
  40. def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None: ...
  41. @abstractmethod
  42. def save_auth_info(self, url: str, username: str, password: str) -> None: ...
  43. class KeyRingNullProvider(KeyRingBaseProvider):
  44. """Keyring null provider"""
  45. has_keyring = False
  46. def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None:
  47. return None
  48. def save_auth_info(self, url: str, username: str, password: str) -> None:
  49. return None
  50. class KeyRingPythonProvider(KeyRingBaseProvider):
  51. """Keyring interface which uses locally imported `keyring`"""
  52. has_keyring = True
  53. def __init__(self) -> None:
  54. import keyring
  55. self.keyring = keyring
  56. def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None:
  57. # Support keyring's get_credential interface which supports getting
  58. # credentials without a username. This is only available for
  59. # keyring>=15.2.0.
  60. if hasattr(self.keyring, "get_credential"):
  61. logger.debug("Getting credentials from keyring for %s", url)
  62. cred = self.keyring.get_credential(url, username)
  63. if cred is not None:
  64. return cred.username, cred.password
  65. return None
  66. if username is not None:
  67. logger.debug("Getting password from keyring for %s", url)
  68. password = self.keyring.get_password(url, username)
  69. if password:
  70. return username, password
  71. return None
  72. def save_auth_info(self, url: str, username: str, password: str) -> None:
  73. self.keyring.set_password(url, username, password)
  74. class KeyRingCliProvider(KeyRingBaseProvider):
  75. """Provider which uses `keyring` cli
  76. Instead of calling the keyring package installed alongside pip
  77. we call keyring on the command line which will enable pip to
  78. use which ever installation of keyring is available first in
  79. PATH.
  80. """
  81. has_keyring = True
  82. def __init__(self, cmd: str) -> None:
  83. self.keyring = cmd
  84. def get_auth_info(self, url: str, username: str | None) -> AuthInfo | None:
  85. # This is the default implementation of keyring.get_credential
  86. # https://github.com/jaraco/keyring/blob/97689324abcf01bd1793d49063e7ca01e03d7d07/keyring/backend.py#L134-L139
  87. if username is not None:
  88. password = self._get_password(url, username)
  89. if password is not None:
  90. return username, password
  91. return None
  92. def save_auth_info(self, url: str, username: str, password: str) -> None:
  93. return self._set_password(url, username, password)
  94. def _get_password(self, service_name: str, username: str) -> str | None:
  95. """Mirror the implementation of keyring.get_password using cli"""
  96. if self.keyring is None:
  97. return None
  98. cmd = [self.keyring, "get", service_name, username]
  99. env = os.environ.copy()
  100. env["PYTHONIOENCODING"] = "utf-8"
  101. res = subprocess.run(
  102. cmd,
  103. stdin=subprocess.DEVNULL,
  104. stdout=subprocess.PIPE,
  105. env=env,
  106. )
  107. if res.returncode:
  108. return None
  109. return res.stdout.decode("utf-8").strip(os.linesep)
  110. def _set_password(self, service_name: str, username: str, password: str) -> None:
  111. """Mirror the implementation of keyring.set_password using cli"""
  112. if self.keyring is None:
  113. return None
  114. env = os.environ.copy()
  115. env["PYTHONIOENCODING"] = "utf-8"
  116. subprocess.run(
  117. [self.keyring, "set", service_name, username],
  118. input=f"{password}{os.linesep}".encode(),
  119. env=env,
  120. check=True,
  121. )
  122. return None
  123. @cache
  124. def get_keyring_provider(provider: str) -> KeyRingBaseProvider:
  125. logger.verbose("Keyring provider requested: %s", provider)
  126. # keyring has previously failed and been disabled
  127. if KEYRING_DISABLED:
  128. provider = "disabled"
  129. if provider in ["import", "auto"]:
  130. try:
  131. impl = KeyRingPythonProvider()
  132. logger.verbose("Keyring provider set: import")
  133. return impl
  134. except ImportError:
  135. pass
  136. except Exception as exc:
  137. # In the event of an unexpected exception
  138. # we should warn the user
  139. msg = "Installed copy of keyring fails with exception %s"
  140. if provider == "auto":
  141. msg = msg + ", trying to find a keyring executable as a fallback"
  142. logger.warning(msg, exc, exc_info=logger.isEnabledFor(logging.DEBUG))
  143. if provider in ["subprocess", "auto"]:
  144. cli = shutil.which("keyring")
  145. if cli and cli.startswith(sysconfig.get_path("scripts")):
  146. # all code within this function is stolen from shutil.which implementation
  147. @typing.no_type_check
  148. def PATH_as_shutil_which_determines_it() -> str:
  149. path = os.environ.get("PATH", None)
  150. if path is None:
  151. try:
  152. path = os.confstr("CS_PATH")
  153. except (AttributeError, ValueError):
  154. # os.confstr() or CS_PATH is not available
  155. path = os.defpath
  156. # bpo-35755: Don't use os.defpath if the PATH environment variable is
  157. # set to an empty string
  158. return path
  159. scripts = Path(sysconfig.get_path("scripts"))
  160. paths = []
  161. for path in PATH_as_shutil_which_determines_it().split(os.pathsep):
  162. p = Path(path)
  163. try:
  164. if not p.samefile(scripts):
  165. paths.append(path)
  166. except FileNotFoundError:
  167. pass
  168. path = os.pathsep.join(paths)
  169. cli = shutil.which("keyring", path=path)
  170. if cli:
  171. logger.verbose("Keyring provider set: subprocess with executable %s", cli)
  172. return KeyRingCliProvider(cli)
  173. logger.verbose("Keyring provider set: disabled")
  174. return KeyRingNullProvider()
  175. class MultiDomainBasicAuth(AuthBase):
  176. def __init__(
  177. self,
  178. prompting: bool = True,
  179. index_urls: list[str] | None = None,
  180. keyring_provider: str = "auto",
  181. ) -> None:
  182. self.prompting = prompting
  183. self.index_urls = index_urls
  184. self.keyring_provider = keyring_provider
  185. self.passwords: dict[str, AuthInfo] = {}
  186. # When the user is prompted to enter credentials and keyring is
  187. # available, we will offer to save them. If the user accepts,
  188. # this value is set to the credentials they entered. After the
  189. # request authenticates, the caller should call
  190. # ``save_credentials`` to save these.
  191. self._credentials_to_save: Credentials | None = None
  192. @property
  193. def keyring_provider(self) -> KeyRingBaseProvider:
  194. return get_keyring_provider(self._keyring_provider)
  195. @keyring_provider.setter
  196. def keyring_provider(self, provider: str) -> None:
  197. # The free function get_keyring_provider has been decorated with
  198. # functools.cache. If an exception occurs in get_keyring_auth that
  199. # cache will be cleared and keyring disabled, take that into account
  200. # if you want to remove this indirection.
  201. self._keyring_provider = provider
  202. @property
  203. def use_keyring(self) -> bool:
  204. # We won't use keyring when --no-input is passed unless
  205. # a specific provider is requested because it might require
  206. # user interaction
  207. return self.prompting or self._keyring_provider not in ["auto", "disabled"]
  208. def _get_keyring_auth(
  209. self,
  210. url: str | None,
  211. username: str | None,
  212. ) -> AuthInfo | None:
  213. """Return the tuple auth for a given url from keyring."""
  214. # Do nothing if no url was provided
  215. if not url:
  216. return None
  217. try:
  218. return self.keyring_provider.get_auth_info(url, username)
  219. except Exception as exc:
  220. # Log the full exception (with stacktrace) at debug, so it'll only
  221. # show up when running in verbose mode.
  222. logger.debug("Keyring is skipped due to an exception", exc_info=True)
  223. # Always log a shortened version of the exception.
  224. logger.warning(
  225. "Keyring is skipped due to an exception: %s",
  226. str(exc),
  227. )
  228. global KEYRING_DISABLED
  229. KEYRING_DISABLED = True
  230. get_keyring_provider.cache_clear()
  231. return None
  232. def _get_index_url(self, url: str) -> str | None:
  233. """Return the original index URL matching the requested URL.
  234. Cached or dynamically generated credentials may work against
  235. the original index URL rather than just the netloc.
  236. The provided url should have had its username and password
  237. removed already. If the original index url had credentials then
  238. they will be included in the return value.
  239. Returns None if no matching index was found, or if --no-index
  240. was specified by the user.
  241. """
  242. if not url or not self.index_urls:
  243. return None
  244. url = remove_auth_from_url(url).rstrip("/") + "/"
  245. parsed_url = urllib.parse.urlsplit(url)
  246. candidates = []
  247. for index in self.index_urls:
  248. index = index.rstrip("/") + "/"
  249. parsed_index = urllib.parse.urlsplit(remove_auth_from_url(index))
  250. if parsed_url == parsed_index:
  251. return index
  252. if parsed_url.netloc != parsed_index.netloc:
  253. continue
  254. candidate = urllib.parse.urlsplit(index)
  255. candidates.append(candidate)
  256. if not candidates:
  257. return None
  258. candidates.sort(
  259. reverse=True,
  260. key=lambda candidate: commonprefix(
  261. [
  262. parsed_url.path,
  263. candidate.path,
  264. ]
  265. ).rfind("/"),
  266. )
  267. return urllib.parse.urlunsplit(candidates[0])
  268. def _get_new_credentials(
  269. self,
  270. original_url: str,
  271. *,
  272. allow_netrc: bool = True,
  273. allow_keyring: bool = False,
  274. ) -> AuthInfo:
  275. """Find and return credentials for the specified URL."""
  276. # Split the credentials and netloc from the url.
  277. url, netloc, url_user_password = split_auth_netloc_from_url(
  278. original_url,
  279. )
  280. # Start with the credentials embedded in the url
  281. username, password = url_user_password
  282. if username is not None and password is not None:
  283. logger.debug("Found credentials in url for %s", netloc)
  284. return url_user_password
  285. # Find a matching index url for this request
  286. index_url = self._get_index_url(url)
  287. if index_url:
  288. # Split the credentials from the url.
  289. index_info = split_auth_netloc_from_url(index_url)
  290. if index_info:
  291. index_url, _, index_url_user_password = index_info
  292. logger.debug("Found index url %s", index_url)
  293. # If an index URL was found, try its embedded credentials
  294. if index_url and index_url_user_password[0] is not None:
  295. username, password = index_url_user_password
  296. if username is not None and password is not None:
  297. logger.debug("Found credentials in index url for %s", netloc)
  298. return index_url_user_password
  299. # Get creds from netrc if we still don't have them
  300. if allow_netrc:
  301. netrc_auth = get_netrc_auth(original_url)
  302. if netrc_auth:
  303. logger.debug("Found credentials in netrc for %s", netloc)
  304. return netrc_auth
  305. # If we don't have a password and keyring is available, use it.
  306. if allow_keyring:
  307. # The index url is more specific than the netloc, so try it first
  308. # fmt: off
  309. kr_auth = (
  310. self._get_keyring_auth(index_url, username) or
  311. self._get_keyring_auth(netloc, username)
  312. )
  313. # fmt: on
  314. if kr_auth:
  315. logger.debug("Found credentials in keyring for %s", netloc)
  316. return kr_auth
  317. return username, password
  318. def _get_url_and_credentials(
  319. self, original_url: str
  320. ) -> tuple[str, str | None, str | None]:
  321. """Return the credentials to use for the provided URL.
  322. If allowed, netrc and keyring may be used to obtain the
  323. correct credentials.
  324. Returns (url_without_credentials, username, password). Note
  325. that even if the original URL contains credentials, this
  326. function may return a different username and password.
  327. """
  328. url, netloc, _ = split_auth_netloc_from_url(original_url)
  329. # Try to get credentials from original url
  330. username, password = self._get_new_credentials(original_url)
  331. # If credentials not found, use any stored credentials for this netloc.
  332. # Do this if either the username or the password is missing.
  333. # This accounts for the situation in which the user has specified
  334. # the username in the index url, but the password comes from keyring.
  335. if (username is None or password is None) and netloc in self.passwords:
  336. un, pw = self.passwords[netloc]
  337. # It is possible that the cached credentials are for a different username,
  338. # in which case the cache should be ignored.
  339. if username is None or username == un:
  340. username, password = un, pw
  341. if username is not None or password is not None:
  342. # Convert the username and password if they're None, so that
  343. # this netloc will show up as "cached" in the conditional above.
  344. # Further, HTTPBasicAuth doesn't accept None, so it makes sense to
  345. # cache the value that is going to be used.
  346. username = username or ""
  347. password = password or ""
  348. # Store any acquired credentials.
  349. self.passwords[netloc] = (username, password)
  350. assert (
  351. # Credentials were found
  352. (username is not None and password is not None)
  353. # Credentials were not found
  354. or (username is None and password is None)
  355. ), f"Could not load credentials from url: {original_url}"
  356. return url, username, password
  357. def __call__(self, req: Request) -> Request:
  358. # Get credentials for this request
  359. url, username, password = self._get_url_and_credentials(req.url)
  360. # Set the url of the request to the url without any credentials
  361. req.url = url
  362. if username is not None and password is not None:
  363. # Send the basic auth with this request
  364. req = HTTPBasicAuth(username, password)(req)
  365. # Attach a hook to handle 401 responses
  366. req.register_hook("response", self.handle_401)
  367. return req
  368. # Factored out to allow for easy patching in tests
  369. def _prompt_for_password(self, netloc: str) -> tuple[str | None, str | None, bool]:
  370. username = ask_input(f"User for {netloc}: ") if self.prompting else None
  371. if not username:
  372. return None, None, False
  373. if self.use_keyring:
  374. auth = self._get_keyring_auth(netloc, username)
  375. if auth and auth[0] is not None and auth[1] is not None:
  376. return auth[0], auth[1], False
  377. password = ask_password("Password: ")
  378. return username, password, True
  379. # Factored out to allow for easy patching in tests
  380. def _should_save_password_to_keyring(self) -> bool:
  381. if (
  382. not self.prompting
  383. or not self.use_keyring
  384. or not self.keyring_provider.has_keyring
  385. ):
  386. return False
  387. return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
  388. def handle_401(self, resp: Response, **kwargs: Any) -> Response:
  389. # We only care about 401 responses, anything else we want to just
  390. # pass through the actual response
  391. if resp.status_code != 401:
  392. return resp
  393. username, password = None, None
  394. # Query the keyring for credentials:
  395. if self.use_keyring:
  396. username, password = self._get_new_credentials(
  397. resp.url,
  398. allow_netrc=False,
  399. allow_keyring=True,
  400. )
  401. # We are not able to prompt the user so simply return the response
  402. if not self.prompting and not username and not password:
  403. return resp
  404. parsed = urllib.parse.urlparse(resp.url)
  405. # Prompt the user for a new username and password
  406. save = False
  407. if not username and not password:
  408. username, password, save = self._prompt_for_password(parsed.netloc)
  409. # Store the new username and password to use for future requests
  410. self._credentials_to_save = None
  411. if username is not None and password is not None:
  412. self.passwords[parsed.netloc] = (username, password)
  413. # Prompt to save the password to keyring
  414. if save and self._should_save_password_to_keyring():
  415. self._credentials_to_save = Credentials(
  416. url=parsed.netloc,
  417. username=username,
  418. password=password,
  419. )
  420. # Consume content and release the original connection to allow our new
  421. # request to reuse the same one.
  422. # The result of the assignment isn't used, it's just needed to consume
  423. # the content.
  424. _ = resp.content
  425. resp.raw.release_conn()
  426. # Add our new username and password to the request
  427. req = HTTPBasicAuth(username or "", password or "")(resp.request)
  428. req.register_hook("response", self.warn_on_401)
  429. # On successful request, save the credentials that were used to
  430. # keyring. (Note that if the user responded "no" above, this member
  431. # is not set and nothing will be saved.)
  432. if self._credentials_to_save:
  433. req.register_hook("response", self.save_credentials)
  434. # Send our new request
  435. new_resp = resp.connection.send(req, **kwargs)
  436. new_resp.history.append(resp)
  437. return new_resp
  438. def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
  439. """Response callback to warn about incorrect credentials."""
  440. if resp.status_code == 401:
  441. logger.warning(
  442. "401 Error, Credentials not correct for %s",
  443. resp.request.url,
  444. )
  445. def save_credentials(self, resp: Response, **kwargs: Any) -> None:
  446. """Response callback to save credentials on success."""
  447. assert (
  448. self.keyring_provider.has_keyring
  449. ), "should never reach here without keyring"
  450. creds = self._credentials_to_save
  451. self._credentials_to_save = None
  452. if creds and resp.status_code < 400:
  453. try:
  454. logger.info("Saving credentials to keyring")
  455. self.keyring_provider.save_auth_info(
  456. creds.url, creds.username, creds.password
  457. )
  458. except Exception:
  459. logger.exception("Failed to save credentials")