client_middleware_digest_auth.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. """
  2. Digest authentication middleware for aiohttp client.
  3. This middleware implements HTTP Digest Authentication according to RFC 7616,
  4. providing a more secure alternative to Basic Authentication. It supports all
  5. standard hash algorithms including MD5, SHA, SHA-256, SHA-512 and their session
  6. variants, as well as both 'auth' and 'auth-int' quality of protection (qop) options.
  7. """
  8. import hashlib
  9. import os
  10. import re
  11. import sys
  12. import time
  13. from typing import (
  14. Callable,
  15. Dict,
  16. Final,
  17. FrozenSet,
  18. List,
  19. Literal,
  20. Tuple,
  21. TypedDict,
  22. Union,
  23. )
  24. from yarl import URL
  25. from . import hdrs
  26. from .client_exceptions import ClientError
  27. from .client_middlewares import ClientHandlerType
  28. from .client_reqrep import ClientRequest, ClientResponse
  29. from .payload import Payload
  30. class DigestAuthChallenge(TypedDict, total=False):
  31. realm: str
  32. nonce: str
  33. qop: str
  34. algorithm: str
  35. opaque: str
  36. domain: str
  37. stale: str
  38. DigestFunctions: Dict[str, Callable[[bytes], "hashlib._Hash"]] = {
  39. "MD5": hashlib.md5,
  40. "MD5-SESS": hashlib.md5,
  41. "SHA": hashlib.sha1,
  42. "SHA-SESS": hashlib.sha1,
  43. "SHA256": hashlib.sha256,
  44. "SHA256-SESS": hashlib.sha256,
  45. "SHA-256": hashlib.sha256,
  46. "SHA-256-SESS": hashlib.sha256,
  47. "SHA512": hashlib.sha512,
  48. "SHA512-SESS": hashlib.sha512,
  49. "SHA-512": hashlib.sha512,
  50. "SHA-512-SESS": hashlib.sha512,
  51. }
  52. # Compile the regex pattern once at module level for performance
  53. _HEADER_PAIRS_PATTERN = re.compile(
  54. r'(?:^|\s|,\s*)(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))'
  55. if sys.version_info < (3, 11)
  56. else r'(?:^|\s|,\s*)((?>\w+))\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))'
  57. # +------------|--------|--|-|-|--|----|------|----|--||-----|-> Match valid start/sep
  58. # +--------|--|-|-|--|----|------|----|--||-----|-> alphanumeric key (atomic
  59. # | | | | | | | | || | group reduces backtracking)
  60. # +--|-|-|--|----|------|----|--||-----|-> maybe whitespace
  61. # | | | | | | | || |
  62. # +-|-|--|----|------|----|--||-----|-> = (delimiter)
  63. # +-|--|----|------|----|--||-----|-> maybe whitespace
  64. # | | | | | || |
  65. # +--|----|------|----|--||-----|-> group quoted or unquoted
  66. # | | | | || |
  67. # +----|------|----|--||-----|-> if quoted...
  68. # +------|----|--||-----|-> anything but " or \
  69. # +----|--||-----|-> escaped characters allowed
  70. # +--||-----|-> or can be empty string
  71. # || |
  72. # +|-----|-> if unquoted...
  73. # +-----|-> anything but , or <space>
  74. # +-> at least one char req'd
  75. )
  76. # RFC 7616: Challenge parameters to extract
  77. CHALLENGE_FIELDS: Final[
  78. Tuple[
  79. Literal["realm", "nonce", "qop", "algorithm", "opaque", "domain", "stale"], ...
  80. ]
  81. ] = (
  82. "realm",
  83. "nonce",
  84. "qop",
  85. "algorithm",
  86. "opaque",
  87. "domain",
  88. "stale",
  89. )
  90. # Supported digest authentication algorithms
  91. # Use a tuple of sorted keys for predictable documentation and error messages
  92. SUPPORTED_ALGORITHMS: Final[Tuple[str, ...]] = tuple(sorted(DigestFunctions.keys()))
  93. # RFC 7616: Fields that require quoting in the Digest auth header
  94. # These fields must be enclosed in double quotes in the Authorization header.
  95. # Algorithm, qop, and nc are never quoted per RFC specifications.
  96. # This frozen set is used by the template-based header construction to
  97. # automatically determine which fields need quotes.
  98. QUOTED_AUTH_FIELDS: Final[FrozenSet[str]] = frozenset(
  99. {"username", "realm", "nonce", "uri", "response", "opaque", "cnonce"}
  100. )
  101. def escape_quotes(value: str) -> str:
  102. """Escape double quotes for HTTP header values."""
  103. return value.replace('"', '\\"')
  104. def unescape_quotes(value: str) -> str:
  105. """Unescape double quotes in HTTP header values."""
  106. return value.replace('\\"', '"')
  107. def parse_header_pairs(header: str) -> Dict[str, str]:
  108. """
  109. Parse key-value pairs from WWW-Authenticate or similar HTTP headers.
  110. This function handles the complex format of WWW-Authenticate header values,
  111. supporting both quoted and unquoted values, proper handling of commas in
  112. quoted values, and whitespace variations per RFC 7616.
  113. Examples of supported formats:
  114. - key1="value1", key2=value2
  115. - key1 = "value1" , key2="value, with, commas"
  116. - key1=value1,key2="value2"
  117. - realm="example.com", nonce="12345", qop="auth"
  118. Args:
  119. header: The header value string to parse
  120. Returns:
  121. Dictionary mapping parameter names to their values
  122. """
  123. return {
  124. stripped_key: unescape_quotes(quoted_val) if quoted_val else unquoted_val
  125. for key, quoted_val, unquoted_val in _HEADER_PAIRS_PATTERN.findall(header)
  126. if (stripped_key := key.strip())
  127. }
  128. class DigestAuthMiddleware:
  129. """
  130. HTTP digest authentication middleware for aiohttp client.
  131. This middleware intercepts 401 Unauthorized responses containing a Digest
  132. authentication challenge, calculates the appropriate digest credentials,
  133. and automatically retries the request with the proper Authorization header.
  134. Features:
  135. - Handles all aspects of Digest authentication handshake automatically
  136. - Supports all standard hash algorithms:
  137. - MD5, MD5-SESS
  138. - SHA, SHA-SESS
  139. - SHA256, SHA256-SESS, SHA-256, SHA-256-SESS
  140. - SHA512, SHA512-SESS, SHA-512, SHA-512-SESS
  141. - Supports 'auth' and 'auth-int' quality of protection modes
  142. - Properly handles quoted strings and parameter parsing
  143. - Includes replay attack protection with client nonce count tracking
  144. - Supports preemptive authentication per RFC 7616 Section 3.6
  145. Standards compliance:
  146. - RFC 7616: HTTP Digest Access Authentication (primary reference)
  147. - RFC 2617: HTTP Authentication (deprecated by RFC 7616)
  148. - RFC 1945: Section 11.1 (username restrictions)
  149. Implementation notes:
  150. The core digest calculation is inspired by the implementation in
  151. https://github.com/requests/requests/blob/v2.18.4/requests/auth.py
  152. with added support for modern digest auth features and error handling.
  153. """
  154. def __init__(
  155. self,
  156. login: str,
  157. password: str,
  158. preemptive: bool = True,
  159. ) -> None:
  160. if login is None:
  161. raise ValueError("None is not allowed as login value")
  162. if password is None:
  163. raise ValueError("None is not allowed as password value")
  164. if ":" in login:
  165. raise ValueError('A ":" is not allowed in username (RFC 1945#section-11.1)')
  166. self._login_str: Final[str] = login
  167. self._login_bytes: Final[bytes] = login.encode("utf-8")
  168. self._password_bytes: Final[bytes] = password.encode("utf-8")
  169. self._last_nonce_bytes = b""
  170. self._nonce_count = 0
  171. self._challenge: DigestAuthChallenge = {}
  172. self._preemptive: bool = preemptive
  173. # Set of URLs defining the protection space
  174. self._protection_space: List[str] = []
  175. async def _encode(
  176. self, method: str, url: URL, body: Union[Payload, Literal[b""]]
  177. ) -> str:
  178. """
  179. Build digest authorization header for the current challenge.
  180. Args:
  181. method: The HTTP method (GET, POST, etc.)
  182. url: The request URL
  183. body: The request body (used for qop=auth-int)
  184. Returns:
  185. A fully formatted Digest authorization header string
  186. Raises:
  187. ClientError: If the challenge is missing required parameters or
  188. contains unsupported values
  189. """
  190. challenge = self._challenge
  191. if "realm" not in challenge:
  192. raise ClientError(
  193. "Malformed Digest auth challenge: Missing 'realm' parameter"
  194. )
  195. if "nonce" not in challenge:
  196. raise ClientError(
  197. "Malformed Digest auth challenge: Missing 'nonce' parameter"
  198. )
  199. # Empty realm values are allowed per RFC 7616 (SHOULD, not MUST, contain host name)
  200. realm = challenge["realm"]
  201. nonce = challenge["nonce"]
  202. # Empty nonce values are not allowed as they are security-critical for replay protection
  203. if not nonce:
  204. raise ClientError(
  205. "Security issue: Digest auth challenge contains empty 'nonce' value"
  206. )
  207. qop_raw = challenge.get("qop", "")
  208. # Preserve original algorithm case for response while using uppercase for processing
  209. algorithm_original = challenge.get("algorithm", "MD5")
  210. algorithm = algorithm_original.upper()
  211. opaque = challenge.get("opaque", "")
  212. # Convert string values to bytes once
  213. nonce_bytes = nonce.encode("utf-8")
  214. realm_bytes = realm.encode("utf-8")
  215. path = URL(url).path_qs
  216. # Process QoP
  217. qop = ""
  218. qop_bytes = b""
  219. if qop_raw:
  220. valid_qops = {"auth", "auth-int"}.intersection(
  221. {q.strip() for q in qop_raw.split(",") if q.strip()}
  222. )
  223. if not valid_qops:
  224. raise ClientError(
  225. f"Digest auth error: Unsupported Quality of Protection (qop) value(s): {qop_raw}"
  226. )
  227. qop = "auth-int" if "auth-int" in valid_qops else "auth"
  228. qop_bytes = qop.encode("utf-8")
  229. if algorithm not in DigestFunctions:
  230. raise ClientError(
  231. f"Digest auth error: Unsupported hash algorithm: {algorithm}. "
  232. f"Supported algorithms: {', '.join(SUPPORTED_ALGORITHMS)}"
  233. )
  234. hash_fn: Final = DigestFunctions[algorithm]
  235. def H(x: bytes) -> bytes:
  236. """RFC 7616 Section 3: Hash function H(data) = hex(hash(data))."""
  237. return hash_fn(x).hexdigest().encode()
  238. def KD(s: bytes, d: bytes) -> bytes:
  239. """RFC 7616 Section 3: KD(secret, data) = H(concat(secret, ":", data))."""
  240. return H(b":".join((s, d)))
  241. # Calculate A1 and A2
  242. A1 = b":".join((self._login_bytes, realm_bytes, self._password_bytes))
  243. A2 = f"{method.upper()}:{path}".encode()
  244. if qop == "auth-int":
  245. if isinstance(body, Payload): # will always be empty bytes unless Payload
  246. entity_bytes = await body.as_bytes() # Get bytes from Payload
  247. else:
  248. entity_bytes = body
  249. entity_hash = H(entity_bytes)
  250. A2 = b":".join((A2, entity_hash))
  251. HA1 = H(A1)
  252. HA2 = H(A2)
  253. # Nonce count handling
  254. if nonce_bytes == self._last_nonce_bytes:
  255. self._nonce_count += 1
  256. else:
  257. self._nonce_count = 1
  258. self._last_nonce_bytes = nonce_bytes
  259. ncvalue = f"{self._nonce_count:08x}"
  260. ncvalue_bytes = ncvalue.encode("utf-8")
  261. # Generate client nonce
  262. cnonce = hashlib.sha1(
  263. b"".join(
  264. [
  265. str(self._nonce_count).encode("utf-8"),
  266. nonce_bytes,
  267. time.ctime().encode("utf-8"),
  268. os.urandom(8),
  269. ]
  270. )
  271. ).hexdigest()[:16]
  272. cnonce_bytes = cnonce.encode("utf-8")
  273. # Special handling for session-based algorithms
  274. if algorithm.upper().endswith("-SESS"):
  275. HA1 = H(b":".join((HA1, nonce_bytes, cnonce_bytes)))
  276. # Calculate the response digest
  277. if qop:
  278. noncebit = b":".join(
  279. (nonce_bytes, ncvalue_bytes, cnonce_bytes, qop_bytes, HA2)
  280. )
  281. response_digest = KD(HA1, noncebit)
  282. else:
  283. response_digest = KD(HA1, b":".join((nonce_bytes, HA2)))
  284. # Define a dict mapping of header fields to their values
  285. # Group fields into always-present, optional, and qop-dependent
  286. header_fields = {
  287. # Always present fields
  288. "username": escape_quotes(self._login_str),
  289. "realm": escape_quotes(realm),
  290. "nonce": escape_quotes(nonce),
  291. "uri": path,
  292. "response": response_digest.decode(),
  293. "algorithm": algorithm_original,
  294. }
  295. # Optional fields
  296. if opaque:
  297. header_fields["opaque"] = escape_quotes(opaque)
  298. # QoP-dependent fields
  299. if qop:
  300. header_fields["qop"] = qop
  301. header_fields["nc"] = ncvalue
  302. header_fields["cnonce"] = cnonce
  303. # Build header using templates for each field type
  304. pairs: List[str] = []
  305. for field, value in header_fields.items():
  306. if field in QUOTED_AUTH_FIELDS:
  307. pairs.append(f'{field}="{value}"')
  308. else:
  309. pairs.append(f"{field}={value}")
  310. return f"Digest {', '.join(pairs)}"
  311. def _in_protection_space(self, url: URL) -> bool:
  312. """
  313. Check if the given URL is within the current protection space.
  314. According to RFC 7616, a URI is in the protection space if any URI
  315. in the protection space is a prefix of it (after both have been made absolute).
  316. """
  317. request_str = str(url)
  318. for space_str in self._protection_space:
  319. # Check if request starts with space URL
  320. if not request_str.startswith(space_str):
  321. continue
  322. # Exact match or space ends with / (proper directory prefix)
  323. if len(request_str) == len(space_str) or space_str[-1] == "/":
  324. return True
  325. # Check next char is / to ensure proper path boundary
  326. if request_str[len(space_str)] == "/":
  327. return True
  328. return False
  329. def _authenticate(self, response: ClientResponse) -> bool:
  330. """
  331. Takes the given response and tries digest-auth, if needed.
  332. Returns true if the original request must be resent.
  333. """
  334. if response.status != 401:
  335. return False
  336. auth_header = response.headers.get("www-authenticate", "")
  337. if not auth_header:
  338. return False # No authentication header present
  339. method, sep, headers = auth_header.partition(" ")
  340. if not sep:
  341. # No space found in www-authenticate header
  342. return False # Malformed auth header, missing scheme separator
  343. if method.lower() != "digest":
  344. # Not a digest auth challenge (could be Basic, Bearer, etc.)
  345. return False
  346. if not headers:
  347. # We have a digest scheme but no parameters
  348. return False # Malformed digest header, missing parameters
  349. # We have a digest auth header with content
  350. if not (header_pairs := parse_header_pairs(headers)):
  351. # Failed to parse any key-value pairs
  352. return False # Malformed digest header, no valid parameters
  353. # Extract challenge parameters
  354. self._challenge = {}
  355. for field in CHALLENGE_FIELDS:
  356. if (value := header_pairs.get(field)) is not None:
  357. self._challenge[field] = value
  358. # Update protection space based on domain parameter or default to origin
  359. origin = response.url.origin()
  360. if domain := self._challenge.get("domain"):
  361. # Parse space-separated list of URIs
  362. self._protection_space = []
  363. for uri in domain.split():
  364. # Remove quotes if present
  365. uri = uri.strip('"')
  366. if uri.startswith("/"):
  367. # Path-absolute, relative to origin
  368. self._protection_space.append(str(origin.join(URL(uri))))
  369. else:
  370. # Absolute URI
  371. self._protection_space.append(str(URL(uri)))
  372. else:
  373. # No domain specified, protection space is entire origin
  374. self._protection_space = [str(origin)]
  375. # Return True only if we found at least one challenge parameter
  376. return bool(self._challenge)
  377. async def __call__(
  378. self, request: ClientRequest, handler: ClientHandlerType
  379. ) -> ClientResponse:
  380. """Run the digest auth middleware."""
  381. response = None
  382. for retry_count in range(2):
  383. # Apply authorization header if:
  384. # 1. This is a retry after 401 (retry_count > 0), OR
  385. # 2. Preemptive auth is enabled AND we have a challenge AND the URL is in protection space
  386. if retry_count > 0 or (
  387. self._preemptive
  388. and self._challenge
  389. and self._in_protection_space(request.url)
  390. ):
  391. request.headers[hdrs.AUTHORIZATION] = await self._encode(
  392. request.method, request.url, request.body
  393. )
  394. # Send the request
  395. response = await handler(request)
  396. # Check if we need to authenticate
  397. if not self._authenticate(response):
  398. break
  399. # At this point, response is guaranteed to be defined
  400. assert response is not None
  401. return response