auth.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. """Network Authentication Helpers
  2. Contains interface (MultiDomainBasicAuth) and associated glue code for
  3. providing credentials in the context of network requests.
  4. """
  5. import urllib.parse
  6. from typing import Any, Dict, List, Optional, Tuple
  7. from pip._vendor.requests.auth import AuthBase, HTTPBasicAuth
  8. from pip._vendor.requests.models import Request, Response
  9. from pip._vendor.requests.utils import get_netrc_auth
  10. from pip._internal.utils.logging import getLogger
  11. from pip._internal.utils.misc import (
  12. ask,
  13. ask_input,
  14. ask_password,
  15. remove_auth_from_url,
  16. split_auth_netloc_from_url,
  17. )
  18. from pip._internal.vcs.versioncontrol import AuthInfo
  19. logger = getLogger(__name__)
  20. Credentials = Tuple[str, str, str]
  21. try:
  22. import keyring
  23. except ImportError:
  24. keyring = None # type: ignore[assignment]
  25. except Exception as exc:
  26. logger.warning(
  27. "Keyring is skipped due to an exception: %s",
  28. str(exc),
  29. )
  30. keyring = None # type: ignore[assignment]
  31. def get_keyring_auth(url: Optional[str], username: Optional[str]) -> Optional[AuthInfo]:
  32. """Return the tuple auth for a given url from keyring."""
  33. global keyring
  34. if not url or not keyring:
  35. return None
  36. try:
  37. try:
  38. get_credential = keyring.get_credential
  39. except AttributeError:
  40. pass
  41. else:
  42. logger.debug("Getting credentials from keyring for %s", url)
  43. cred = get_credential(url, username)
  44. if cred is not None:
  45. return cred.username, cred.password
  46. return None
  47. if username:
  48. logger.debug("Getting password from keyring for %s", url)
  49. password = keyring.get_password(url, username)
  50. if password:
  51. return username, password
  52. except Exception as exc:
  53. logger.warning(
  54. "Keyring is skipped due to an exception: %s",
  55. str(exc),
  56. )
  57. keyring = None # type: ignore[assignment]
  58. return None
  59. class MultiDomainBasicAuth(AuthBase):
  60. def __init__(
  61. self, prompting: bool = True, index_urls: Optional[List[str]] = None
  62. ) -> None:
  63. self.prompting = prompting
  64. self.index_urls = index_urls
  65. self.passwords: Dict[str, AuthInfo] = {}
  66. # When the user is prompted to enter credentials and keyring is
  67. # available, we will offer to save them. If the user accepts,
  68. # this value is set to the credentials they entered. After the
  69. # request authenticates, the caller should call
  70. # ``save_credentials`` to save these.
  71. self._credentials_to_save: Optional[Credentials] = None
  72. def _get_index_url(self, url: str) -> Optional[str]:
  73. """Return the original index URL matching the requested URL.
  74. Cached or dynamically generated credentials may work against
  75. the original index URL rather than just the netloc.
  76. The provided url should have had its username and password
  77. removed already. If the original index url had credentials then
  78. they will be included in the return value.
  79. Returns None if no matching index was found, or if --no-index
  80. was specified by the user.
  81. """
  82. if not url or not self.index_urls:
  83. return None
  84. for u in self.index_urls:
  85. prefix = remove_auth_from_url(u).rstrip("/") + "/"
  86. if url.startswith(prefix):
  87. return u
  88. return None
  89. def _get_new_credentials(
  90. self,
  91. original_url: str,
  92. allow_netrc: bool = True,
  93. allow_keyring: bool = False,
  94. ) -> AuthInfo:
  95. """Find and return credentials for the specified URL."""
  96. # Split the credentials and netloc from the url.
  97. url, netloc, url_user_password = split_auth_netloc_from_url(
  98. original_url,
  99. )
  100. # Start with the credentials embedded in the url
  101. username, password = url_user_password
  102. if username is not None and password is not None:
  103. logger.debug("Found credentials in url for %s", netloc)
  104. return url_user_password
  105. # Find a matching index url for this request
  106. index_url = self._get_index_url(url)
  107. if index_url:
  108. # Split the credentials from the url.
  109. index_info = split_auth_netloc_from_url(index_url)
  110. if index_info:
  111. index_url, _, index_url_user_password = index_info
  112. logger.debug("Found index url %s", index_url)
  113. # If an index URL was found, try its embedded credentials
  114. if index_url and index_url_user_password[0] is not None:
  115. username, password = index_url_user_password
  116. if username is not None and password is not None:
  117. logger.debug("Found credentials in index url for %s", netloc)
  118. return index_url_user_password
  119. # Get creds from netrc if we still don't have them
  120. if allow_netrc:
  121. netrc_auth = get_netrc_auth(original_url)
  122. if netrc_auth:
  123. logger.debug("Found credentials in netrc for %s", netloc)
  124. return netrc_auth
  125. # If we don't have a password and keyring is available, use it.
  126. if allow_keyring:
  127. # The index url is more specific than the netloc, so try it first
  128. # fmt: off
  129. kr_auth = (
  130. get_keyring_auth(index_url, username) or
  131. get_keyring_auth(netloc, username)
  132. )
  133. # fmt: on
  134. if kr_auth:
  135. logger.debug("Found credentials in keyring for %s", netloc)
  136. return kr_auth
  137. return username, password
  138. def _get_url_and_credentials(
  139. self, original_url: str
  140. ) -> Tuple[str, Optional[str], Optional[str]]:
  141. """Return the credentials to use for the provided URL.
  142. If allowed, netrc and keyring may be used to obtain the
  143. correct credentials.
  144. Returns (url_without_credentials, username, password). Note
  145. that even if the original URL contains credentials, this
  146. function may return a different username and password.
  147. """
  148. url, netloc, _ = split_auth_netloc_from_url(original_url)
  149. # Try to get credentials from original url
  150. username, password = self._get_new_credentials(original_url)
  151. # If credentials not found, use any stored credentials for this netloc.
  152. # Do this if either the username or the password is missing.
  153. # This accounts for the situation in which the user has specified
  154. # the username in the index url, but the password comes from keyring.
  155. if (username is None or password is None) and netloc in self.passwords:
  156. un, pw = self.passwords[netloc]
  157. # It is possible that the cached credentials are for a different username,
  158. # in which case the cache should be ignored.
  159. if username is None or username == un:
  160. username, password = un, pw
  161. if username is not None or password is not None:
  162. # Convert the username and password if they're None, so that
  163. # this netloc will show up as "cached" in the conditional above.
  164. # Further, HTTPBasicAuth doesn't accept None, so it makes sense to
  165. # cache the value that is going to be used.
  166. username = username or ""
  167. password = password or ""
  168. # Store any acquired credentials.
  169. self.passwords[netloc] = (username, password)
  170. assert (
  171. # Credentials were found
  172. (username is not None and password is not None)
  173. # Credentials were not found
  174. or (username is None and password is None)
  175. ), f"Could not load credentials from url: {original_url}"
  176. return url, username, password
  177. def __call__(self, req: Request) -> Request:
  178. # Get credentials for this request
  179. url, username, password = self._get_url_and_credentials(req.url)
  180. # Set the url of the request to the url without any credentials
  181. req.url = url
  182. if username is not None and password is not None:
  183. # Send the basic auth with this request
  184. req = HTTPBasicAuth(username, password)(req)
  185. # Attach a hook to handle 401 responses
  186. req.register_hook("response", self.handle_401)
  187. return req
  188. # Factored out to allow for easy patching in tests
  189. def _prompt_for_password(
  190. self, netloc: str
  191. ) -> Tuple[Optional[str], Optional[str], bool]:
  192. username = ask_input(f"User for {netloc}: ")
  193. if not username:
  194. return None, None, False
  195. auth = get_keyring_auth(netloc, username)
  196. if auth and auth[0] is not None and auth[1] is not None:
  197. return auth[0], auth[1], False
  198. password = ask_password("Password: ")
  199. return username, password, True
  200. # Factored out to allow for easy patching in tests
  201. def _should_save_password_to_keyring(self) -> bool:
  202. if not keyring:
  203. return False
  204. return ask("Save credentials to keyring [y/N]: ", ["y", "n"]) == "y"
  205. def handle_401(self, resp: Response, **kwargs: Any) -> Response:
  206. # We only care about 401 responses, anything else we want to just
  207. # pass through the actual response
  208. if resp.status_code != 401:
  209. return resp
  210. # We are not able to prompt the user so simply return the response
  211. if not self.prompting:
  212. return resp
  213. parsed = urllib.parse.urlparse(resp.url)
  214. # Query the keyring for credentials:
  215. username, password = self._get_new_credentials(
  216. resp.url,
  217. allow_netrc=False,
  218. allow_keyring=True,
  219. )
  220. # Prompt the user for a new username and password
  221. save = False
  222. if not username and not password:
  223. username, password, save = self._prompt_for_password(parsed.netloc)
  224. # Store the new username and password to use for future requests
  225. self._credentials_to_save = None
  226. if username is not None and password is not None:
  227. self.passwords[parsed.netloc] = (username, password)
  228. # Prompt to save the password to keyring
  229. if save and self._should_save_password_to_keyring():
  230. self._credentials_to_save = (parsed.netloc, username, password)
  231. # Consume content and release the original connection to allow our new
  232. # request to reuse the same one.
  233. resp.content
  234. resp.raw.release_conn()
  235. # Add our new username and password to the request
  236. req = HTTPBasicAuth(username or "", password or "")(resp.request)
  237. req.register_hook("response", self.warn_on_401)
  238. # On successful request, save the credentials that were used to
  239. # keyring. (Note that if the user responded "no" above, this member
  240. # is not set and nothing will be saved.)
  241. if self._credentials_to_save:
  242. req.register_hook("response", self.save_credentials)
  243. # Send our new request
  244. new_resp = resp.connection.send(req, **kwargs)
  245. new_resp.history.append(resp)
  246. return new_resp
  247. def warn_on_401(self, resp: Response, **kwargs: Any) -> None:
  248. """Response callback to warn about incorrect credentials."""
  249. if resp.status_code == 401:
  250. logger.warning(
  251. "401 Error, Credentials not correct for %s",
  252. resp.request.url,
  253. )
  254. def save_credentials(self, resp: Response, **kwargs: Any) -> None:
  255. """Response callback to save credentials on success."""
  256. assert keyring is not None, "should never reach here without keyring"
  257. if not keyring:
  258. return
  259. creds = self._credentials_to_save
  260. self._credentials_to_save = None
  261. if creds and resp.status_code < 400:
  262. try:
  263. logger.info("Saving credentials to keyring")
  264. keyring.set_password(*creds)
  265. except Exception:
  266. logger.exception("Failed to save credentials")