auth.py 20 KB

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