auth.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. """
  2. requests.auth
  3. ~~~~~~~~~~~~~
  4. This module contains the authentication handlers for Requests.
  5. """
  6. from __future__ import annotations
  7. import hashlib
  8. import os
  9. import re
  10. import threading
  11. import time
  12. import warnings
  13. from base64 import b64encode
  14. from typing import TYPE_CHECKING, Any, Final, cast, overload
  15. from ._internal_utils import to_native_string
  16. from .compat import basestring, str, urlparse
  17. from .cookies import extract_cookies_to_jar
  18. from .utils import parse_dict_header
  19. if TYPE_CHECKING:
  20. from http.cookiejar import CookieJar
  21. from typing import Any
  22. from .models import PreparedRequest, Response
  23. CONTENT_TYPE_FORM_URLENCODED: Final = "application/x-www-form-urlencoded"
  24. CONTENT_TYPE_MULTI_PART: Final = "multipart/form-data"
  25. def _basic_auth_str(username: bytes | str, password: bytes | str) -> str:
  26. """Returns a Basic Auth string."""
  27. # "I want us to put a big-ol' comment on top of it that
  28. # says that this behaviour is dumb but we need to preserve
  29. # it because people are relying on it."
  30. # - Lukasa
  31. #
  32. # These are here solely to maintain backwards compatibility
  33. # for things like ints. This will be removed in 3.0.0.
  34. if not isinstance(username, basestring): # type: ignore[reportUnnecessaryIsInstance] # runtime guard for non-str/bytes
  35. warnings.warn(
  36. "Non-string usernames will no longer be supported in Requests "
  37. f"3.0.0. Please convert the object you've passed in ({username!r}) to "
  38. "a string or bytes object in the near future to avoid "
  39. "problems.",
  40. category=DeprecationWarning,
  41. )
  42. username = str(username)
  43. if not isinstance(password, basestring): # type: ignore[reportUnnecessaryIsInstance] # runtime guard for non-str/bytes
  44. warnings.warn(
  45. "Non-string passwords will no longer be supported in Requests "
  46. f"3.0.0. Please convert the object you've passed in ({type(password)!r}) to "
  47. "a string or bytes object in the near future to avoid "
  48. "problems.",
  49. category=DeprecationWarning,
  50. )
  51. password = str(password)
  52. # -- End Removal --
  53. if isinstance(username, str):
  54. username = username.encode("latin1")
  55. if isinstance(password, str):
  56. password = password.encode("latin1")
  57. authstr = "Basic " + to_native_string(
  58. b64encode(b":".join((username, password))).strip()
  59. )
  60. return authstr
  61. class AuthBase:
  62. """Base class that all auth implementations derive from"""
  63. def __call__(self, r: PreparedRequest) -> PreparedRequest:
  64. raise NotImplementedError("Auth hooks must be callable.")
  65. class HTTPBasicAuth(AuthBase):
  66. """Attaches HTTP Basic Authentication to the given Request object."""
  67. username: bytes | str
  68. password: bytes | str
  69. @overload
  70. def __init__(self, username: str, password: str) -> None: ...
  71. @overload
  72. def __init__(self, username: bytes, password: bytes) -> None: ...
  73. def __init__(self, username: bytes | str, password: bytes | str) -> None:
  74. self.username = username
  75. self.password = password
  76. def __eq__(self, other: object) -> bool:
  77. return all(
  78. [
  79. self.username == getattr(other, "username", None),
  80. self.password == getattr(other, "password", None),
  81. ]
  82. )
  83. def __ne__(self, other: Any) -> bool:
  84. return not self == other
  85. def __call__(self, r: PreparedRequest) -> PreparedRequest:
  86. r.headers["Authorization"] = _basic_auth_str(self.username, self.password)
  87. return r
  88. class HTTPProxyAuth(HTTPBasicAuth):
  89. """Attaches HTTP Proxy Authentication to a given Request object."""
  90. def __call__(self, r: PreparedRequest) -> PreparedRequest:
  91. r.headers["Proxy-Authorization"] = _basic_auth_str(self.username, self.password)
  92. return r
  93. class HTTPDigestAuth(AuthBase):
  94. """Attaches HTTP Digest Authentication to the given Request object."""
  95. username: bytes | str
  96. password: bytes | str
  97. _thread_local: threading.local
  98. last_nonce: str
  99. nonce_count: int
  100. chal: dict[str, str]
  101. pos: int | None
  102. num_401_calls: int | None
  103. @overload
  104. def __init__(self, username: str, password: str) -> None: ...
  105. @overload
  106. def __init__(self, username: bytes, password: bytes) -> None: ...
  107. def __init__(self, username: bytes | str, password: bytes | str) -> None:
  108. self.username = username
  109. self.password = password
  110. # Keep state in per-thread local storage
  111. self._thread_local = threading.local()
  112. def init_per_thread_state(self) -> None:
  113. # Ensure state is initialized just once per-thread
  114. if not hasattr(self._thread_local, "init"):
  115. self._thread_local.init = True
  116. self._thread_local.last_nonce = ""
  117. self._thread_local.nonce_count = 0
  118. self._thread_local.chal = {}
  119. self._thread_local.pos = None
  120. self._thread_local.num_401_calls = None
  121. def build_digest_header(self, method: str, url: str) -> str | None:
  122. """
  123. :rtype: str
  124. """
  125. realm = self._thread_local.chal["realm"]
  126. nonce = self._thread_local.chal["nonce"]
  127. qop = self._thread_local.chal.get("qop")
  128. algorithm = self._thread_local.chal.get("algorithm")
  129. opaque = self._thread_local.chal.get("opaque")
  130. hash_utf8 = None
  131. if algorithm is None:
  132. _algorithm = "MD5"
  133. else:
  134. _algorithm = algorithm.upper()
  135. # lambdas assume digest modules are imported at the top level
  136. if _algorithm == "MD5" or _algorithm == "MD5-SESS":
  137. def md5_utf8(x: str | bytes) -> str:
  138. if isinstance(x, str):
  139. x = x.encode("utf-8")
  140. return hashlib.md5(x, usedforsecurity=False).hexdigest()
  141. hash_utf8 = md5_utf8
  142. elif _algorithm == "SHA":
  143. def sha_utf8(x: str | bytes) -> str:
  144. if isinstance(x, str):
  145. x = x.encode("utf-8")
  146. return hashlib.sha1(x, usedforsecurity=False).hexdigest()
  147. hash_utf8 = sha_utf8
  148. elif _algorithm == "SHA-256":
  149. def sha256_utf8(x: str | bytes) -> str:
  150. if isinstance(x, str):
  151. x = x.encode("utf-8")
  152. return hashlib.sha256(x, usedforsecurity=False).hexdigest()
  153. hash_utf8 = sha256_utf8
  154. elif _algorithm == "SHA-512":
  155. def sha512_utf8(x: str | bytes) -> str:
  156. if isinstance(x, str):
  157. x = x.encode("utf-8")
  158. return hashlib.sha512(x, usedforsecurity=False).hexdigest()
  159. hash_utf8 = sha512_utf8
  160. if hash_utf8 is None:
  161. return None
  162. def KD(s: str, d: str) -> str:
  163. return hash_utf8(f"{s}:{d}")
  164. # XXX not implemented yet
  165. entdig = None
  166. p_parsed = urlparse(url)
  167. #: path is request-uri defined in RFC 2616 which should not be empty
  168. path = p_parsed.path or "/"
  169. if p_parsed.query:
  170. path += f"?{p_parsed.query}"
  171. A1 = f"{self.username}:{realm}:{self.password}"
  172. A2 = f"{method}:{path}"
  173. HA1 = hash_utf8(A1)
  174. HA2 = hash_utf8(A2)
  175. if nonce == self._thread_local.last_nonce:
  176. self._thread_local.nonce_count += 1
  177. else:
  178. self._thread_local.nonce_count = 1
  179. ncvalue = f"{self._thread_local.nonce_count:08x}"
  180. s = str(self._thread_local.nonce_count).encode("utf-8")
  181. s += nonce.encode("utf-8")
  182. s += time.ctime().encode("utf-8")
  183. s += os.urandom(8)
  184. cnonce = hashlib.sha1(s, usedforsecurity=False).hexdigest()[:16]
  185. if _algorithm == "MD5-SESS":
  186. HA1 = hash_utf8(f"{HA1}:{nonce}:{cnonce}") # type: ignore[reportConstantRedefinition] # RFC 2617 terminology
  187. if not qop:
  188. respdig = KD(HA1, f"{nonce}:{HA2}")
  189. elif qop == "auth" or "auth" in qop.split(","):
  190. noncebit = f"{nonce}:{ncvalue}:{cnonce}:auth:{HA2}"
  191. respdig = KD(HA1, noncebit)
  192. else:
  193. # XXX handle auth-int.
  194. return None
  195. self._thread_local.last_nonce = nonce
  196. # XXX should the partial digests be encoded too?
  197. base = (
  198. f'username="{self.username}", realm="{realm}", nonce="{nonce}", '
  199. f'uri="{path}", response="{respdig}"'
  200. )
  201. if opaque:
  202. base += f', opaque="{opaque}"'
  203. if algorithm:
  204. base += f', algorithm="{algorithm}"'
  205. if entdig:
  206. base += f', digest="{entdig}"'
  207. if qop:
  208. base += f', qop="auth", nc={ncvalue}, cnonce="{cnonce}"'
  209. return f"Digest {base}"
  210. def handle_redirect(self, r: Response, **kwargs: Any) -> None:
  211. """Reset num_401_calls counter on redirects."""
  212. if r.is_redirect:
  213. self._thread_local.num_401_calls = 1
  214. def handle_401(self, r: Response, **kwargs: Any) -> Response:
  215. """
  216. Takes the given response and tries digest-auth, if needed.
  217. :rtype: requests.Response
  218. """
  219. # If response is not 4xx, do not auth
  220. # See https://github.com/psf/requests/issues/3772
  221. if not 400 <= r.status_code < 500:
  222. self._thread_local.num_401_calls = 1
  223. return r
  224. if self._thread_local.pos is not None:
  225. # Rewind the file position indicator of the body to where
  226. # it was to resend the request.
  227. if (seek := getattr(r.request.body, "seek", None)) is not None:
  228. seek(self._thread_local.pos)
  229. s_auth = r.headers.get("www-authenticate", "")
  230. if "digest" in s_auth.lower() and self._thread_local.num_401_calls < 2:
  231. self._thread_local.num_401_calls += 1
  232. pat = re.compile(r"digest ", flags=re.IGNORECASE)
  233. self._thread_local.chal = parse_dict_header(pat.sub("", s_auth, count=1))
  234. # Consume content and release the original connection
  235. # to allow our new request to reuse the same one.
  236. r.content
  237. r.close()
  238. prep = r.request.copy()
  239. cookie_jar = cast("CookieJar", prep._cookies) # type: ignore[reportPrivateUsage]
  240. extract_cookies_to_jar(cookie_jar, r.request, r.raw)
  241. prep.prepare_cookies(cookie_jar)
  242. _digest_auth = self.build_digest_header(
  243. cast(str, prep.method), cast(str, prep.url)
  244. )
  245. if _digest_auth:
  246. prep.headers["Authorization"] = _digest_auth
  247. _r = r.connection.send(prep, **kwargs)
  248. _r.history.append(r)
  249. _r.request = prep
  250. return _r
  251. self._thread_local.num_401_calls = 1
  252. return r
  253. def __call__(self, r: PreparedRequest) -> PreparedRequest:
  254. # Initialize per-thread state, if needed
  255. self.init_per_thread_state()
  256. # If we have a saved nonce, skip the 401
  257. if self._thread_local.last_nonce:
  258. _digest_auth = self.build_digest_header(
  259. cast(str, r.method), cast(str, r.url)
  260. )
  261. if _digest_auth:
  262. r.headers["Authorization"] = _digest_auth
  263. if (tell := getattr(r.body, "tell", None)) is not None:
  264. self._thread_local.pos = tell()
  265. else:
  266. # In the case of HTTPDigestAuth being reused and the body of
  267. # the previous request was a file-like object, pos has the
  268. # file position of the previous body. Ensure it's set to
  269. # None.
  270. self._thread_local.pos = None
  271. r.register_hook("response", self.handle_401)
  272. r.register_hook("response", self.handle_redirect)
  273. self._thread_local.num_401_calls = 1
  274. return r
  275. def __eq__(self, other: object) -> bool:
  276. return all(
  277. [
  278. self.username == getattr(other, "username", None),
  279. self.password == getattr(other, "password", None),
  280. ]
  281. )
  282. def __ne__(self, other: Any) -> bool:
  283. return not self == other