urban-http 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urban_http/__init__.py +59 -0
- urban_http/cli.py +119 -0
- urban_http/command.py +61 -0
- urban_http/cookies.py +101 -0
- urban_http/digest.py +113 -0
- urban_http/execute.py +349 -0
- urban_http/glob.py +95 -0
- urban_http/model.py +132 -0
- urban_http/net.py +170 -0
- urban_http/openapi.py +345 -0
- urban_http/parse.py +810 -0
- urban_http/postman.py +221 -0
- urban_http/protocols/__init__.py +59 -0
- urban_http/protocols/file.py +73 -0
- urban_http/protocols/ftp.py +103 -0
- urban_http/protocols/imap.py +99 -0
- urban_http/protocols/mqtt.py +81 -0
- urban_http/protocols/parse.py +314 -0
- urban_http/protocols/pop3.py +72 -0
- urban_http/protocols/registry.py +35 -0
- urban_http/protocols/scp.py +83 -0
- urban_http/protocols/sftp.py +82 -0
- urban_http/protocols/smb.py +98 -0
- urban_http/protocols/smtp.py +88 -0
- urban_http/protocols/tftp.py +119 -0
- urban_http/protocols/types.py +119 -0
- urban_http/protocols/ws.py +91 -0
- urban_http/tokenize.py +189 -0
- urban_http/variables.py +114 -0
- urban_http-0.1.0.dist-info/METADATA +235 -0
- urban_http-0.1.0.dist-info/RECORD +34 -0
- urban_http-0.1.0.dist-info/WHEEL +4 -0
- urban_http-0.1.0.dist-info/entry_points.txt +2 -0
- urban_http-0.1.0.dist-info/licenses/LICENSE +21 -0
urban_http/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""urban-http : transformer une commande curl ou une collection Postman en appel
|
|
2
|
+
exécutable — HTTP via la bibliothèque standard, et SMTP/FTP/SFTP/POP3/MQTT/TFTP/
|
|
3
|
+
FILE/SMB/WebSocket via des adaptateurs (extras PyPI pour ceux qui le nécessitent).
|
|
4
|
+
|
|
5
|
+
Exemple :
|
|
6
|
+
from urban_http import parse_curl, execute
|
|
7
|
+
|
|
8
|
+
result = parse_curl("curl -X POST https://api.example.com -d '{\\"a\\":1}'")
|
|
9
|
+
resp = execute(result.requests[0]).response
|
|
10
|
+
print(resp.status, resp.text())
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .model import (
|
|
15
|
+
AuthSpec,
|
|
16
|
+
BodySpec,
|
|
17
|
+
DataItem,
|
|
18
|
+
FormField,
|
|
19
|
+
HttpRequestModel,
|
|
20
|
+
ParseResult,
|
|
21
|
+
ParseWarning,
|
|
22
|
+
RequestOptions,
|
|
23
|
+
)
|
|
24
|
+
from .tokenize import tokenize, tokenize_full
|
|
25
|
+
from .parse import parse_curl
|
|
26
|
+
from .postman import is_postman_collection, parse_postman
|
|
27
|
+
from .variables import parse_postman_environment
|
|
28
|
+
from .execute import ExecuteResult, HttpResponse, PreparedRequest, auth_header, execute, prepare_request
|
|
29
|
+
from .glob import GlobError, expand_curl_globs
|
|
30
|
+
from .digest import build_digest_authorization, parse_digest_challenge
|
|
31
|
+
from .cookies import cookies_for_url, parse_netscape_cookies
|
|
32
|
+
from .command import ParsedCommand, RunResult, parse_command, run
|
|
33
|
+
from .openapi import CurlOperation, OperationParam, openapi_to_curl
|
|
34
|
+
from .protocols import (
|
|
35
|
+
ProtocolContext,
|
|
36
|
+
ProtocolRequest,
|
|
37
|
+
ProtocolResult,
|
|
38
|
+
execute_protocol,
|
|
39
|
+
parse_protocol_command,
|
|
40
|
+
register_default_adapters,
|
|
41
|
+
register_protocol,
|
|
42
|
+
registered_schemes,
|
|
43
|
+
unregister_protocol,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AuthSpec", "BodySpec", "DataItem", "FormField", "HttpRequestModel", "ParseResult",
|
|
50
|
+
"ParseWarning", "RequestOptions", "tokenize", "tokenize_full", "parse_curl",
|
|
51
|
+
"is_postman_collection", "parse_postman", "parse_postman_environment",
|
|
52
|
+
"ExecuteResult", "HttpResponse", "PreparedRequest", "auth_header", "execute", "prepare_request",
|
|
53
|
+
"GlobError", "expand_curl_globs", "build_digest_authorization", "parse_digest_challenge",
|
|
54
|
+
"cookies_for_url", "parse_netscape_cookies", "ParsedCommand", "RunResult", "parse_command",
|
|
55
|
+
"run", "ProtocolContext", "ProtocolRequest", "ProtocolResult", "execute_protocol",
|
|
56
|
+
"parse_protocol_command", "register_default_adapters", "register_protocol",
|
|
57
|
+
"registered_schemes", "unregister_protocol",
|
|
58
|
+
"openapi_to_curl", "CurlOperation", "OperationParam", "__version__",
|
|
59
|
+
]
|
urban_http/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Interface en ligne de commande : `urban-http "curl ..."`.
|
|
2
|
+
|
|
3
|
+
Analyse une commande curl / Postman et affiche la requête obtenue, ou l'exécute
|
|
4
|
+
avec --run.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
from . import parse_command, parse_postman, is_postman_collection, openapi_to_curl, register_default_adapters, run
|
|
14
|
+
from .model import HttpRequestModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_vars(pairs: List[str]) -> Dict[str, str]:
|
|
18
|
+
out: Dict[str, str] = {}
|
|
19
|
+
for p in pairs or []:
|
|
20
|
+
if "=" in p:
|
|
21
|
+
k, v = p.split("=", 1)
|
|
22
|
+
out[k] = v
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _print_http(req: HttpRequestModel) -> None:
|
|
27
|
+
print(f"{req.method} {req.url}")
|
|
28
|
+
for n, v in req.headers:
|
|
29
|
+
print(f" {n}: {v}")
|
|
30
|
+
if req.body and req.body.text:
|
|
31
|
+
print(f" corps: {req.body.text}")
|
|
32
|
+
for w in req.warnings:
|
|
33
|
+
print(f" [{w.level}] {w.message}", file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: List[str] = None) -> int:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="urban-http", description="Transformer une commande curl / Postman en appel exécutable.")
|
|
38
|
+
parser.add_argument("command", nargs="?", help="commande curl (entre guillemets), ou '-' pour lire une collection Postman sur stdin")
|
|
39
|
+
parser.add_argument("--run", action="store_true", help="exécuter la requête au lieu de seulement l'analyser")
|
|
40
|
+
parser.add_argument("--var", action="append", default=[], metavar="CLE=VALEUR", help="variable (répétable)")
|
|
41
|
+
parser.add_argument("--json", action="store_true", help="sortie JSON")
|
|
42
|
+
parser.add_argument("--openapi", metavar="FICHIER", help="générer les commandes curl d'un contrat OpenAPI (ou '-' pour stdin)")
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
variables = _parse_vars(args.var)
|
|
46
|
+
|
|
47
|
+
if args.openapi:
|
|
48
|
+
return _openapi_mode(args.openapi, args.json)
|
|
49
|
+
|
|
50
|
+
if not args.command:
|
|
51
|
+
parser.error("une commande curl ou --openapi est requis")
|
|
52
|
+
command = sys.stdin.read() if args.command == "-" else args.command
|
|
53
|
+
|
|
54
|
+
if is_postman_collection(command):
|
|
55
|
+
result = parse_postman(command, variables)
|
|
56
|
+
for req in result.requests:
|
|
57
|
+
_print_http(req)
|
|
58
|
+
print()
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if args.run:
|
|
62
|
+
register_default_adapters()
|
|
63
|
+
res = run(command, variables)
|
|
64
|
+
if res.kind == "http":
|
|
65
|
+
resp = res.http.response
|
|
66
|
+
print(f"{resp.status} {resp.reason}")
|
|
67
|
+
print(resp.text())
|
|
68
|
+
else:
|
|
69
|
+
r = res.protocol
|
|
70
|
+
print(r.summary)
|
|
71
|
+
if r.body:
|
|
72
|
+
sys.stdout.buffer.write(r.body)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
parsed = parse_command(command, variables)
|
|
76
|
+
if parsed.kind == "http":
|
|
77
|
+
if args.json:
|
|
78
|
+
print(json.dumps([_req_to_dict(r) for r in parsed.requests], indent=2, ensure_ascii=False))
|
|
79
|
+
else:
|
|
80
|
+
for req in parsed.requests:
|
|
81
|
+
_print_http(req)
|
|
82
|
+
print()
|
|
83
|
+
else:
|
|
84
|
+
r = parsed.request
|
|
85
|
+
if r is None:
|
|
86
|
+
for w in parsed.warnings:
|
|
87
|
+
print(f"[{w.level}] {w.message}", file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
print(f"{r.scheme}:// {r.direction} {r.host}:{r.port}{r.path}")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _openapi_mode(source: str, as_json: bool) -> int:
|
|
94
|
+
contract = sys.stdin.read() if source == "-" else open(source, "r", encoding="utf-8").read()
|
|
95
|
+
operations = openapi_to_curl(contract)
|
|
96
|
+
if as_json:
|
|
97
|
+
print(json.dumps([op.to_dict() for op in operations], indent=2, ensure_ascii=False))
|
|
98
|
+
return 0
|
|
99
|
+
for op in operations:
|
|
100
|
+
print(f"# {op.method} {op.path}" + (f" — {op.summary}" if op.summary else ""))
|
|
101
|
+
print(op.curl)
|
|
102
|
+
if op.example_variables:
|
|
103
|
+
print("# variables : " + ", ".join(f"{k}={v}" for k, v in op.example_variables.items()))
|
|
104
|
+
print()
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _req_to_dict(req: HttpRequestModel) -> dict:
|
|
109
|
+
return {
|
|
110
|
+
"method": req.method,
|
|
111
|
+
"url": req.url,
|
|
112
|
+
"headers": [list(h) for h in req.headers],
|
|
113
|
+
"body": req.body.text if req.body else None,
|
|
114
|
+
"warnings": [{"code": w.code, "level": w.level, "message": w.message} for w in req.warnings],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
raise SystemExit(main())
|
urban_http/command.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Point d'entrée unifié : parser une commande curl quel que soit son protocole,
|
|
2
|
+
et l'exécuter via le bon moteur (HTTP via urllib, ou un adaptateur de protocole).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from .execute import ExecuteResult, execute
|
|
10
|
+
from .model import HttpRequestModel, ParseWarning
|
|
11
|
+
from .parse import parse_curl
|
|
12
|
+
from .protocols.parse import parse_protocol_command
|
|
13
|
+
from .protocols.registry import execute_protocol
|
|
14
|
+
from .protocols.types import ProtocolContext, ProtocolRequest, ProtocolResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ParsedCommand:
|
|
19
|
+
kind: str # "http" | "protocol"
|
|
20
|
+
warnings: List[ParseWarning] = field(default_factory=list)
|
|
21
|
+
requests: List[HttpRequestModel] = field(default_factory=list)
|
|
22
|
+
scheme: str = ""
|
|
23
|
+
request: Optional[ProtocolRequest] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_command(command: str, variables: Optional[Dict[str, str]] = None) -> ParsedCommand:
|
|
27
|
+
"""Parse une commande curl de n'importe quel protocole."""
|
|
28
|
+
probe = parse_protocol_command(command, variables)
|
|
29
|
+
if probe.is_http:
|
|
30
|
+
result = parse_curl(command, variables)
|
|
31
|
+
return ParsedCommand(kind="http", requests=result.requests, warnings=result.warnings)
|
|
32
|
+
return ParsedCommand(kind="protocol", scheme=probe.scheme, request=probe.request, warnings=probe.warnings)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RunResult:
|
|
37
|
+
kind: str # "http" | "protocol"
|
|
38
|
+
http: Optional[ExecuteResult] = None
|
|
39
|
+
protocol: Optional[ProtocolResult] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(
|
|
43
|
+
command: str,
|
|
44
|
+
variables: Optional[Dict[str, str]] = None,
|
|
45
|
+
file_reader: Optional[Callable[[str], Union[bytes, str]]] = None,
|
|
46
|
+
write_file: Optional[Callable[[str, bytes], None]] = None,
|
|
47
|
+
client_factory: Optional[object] = None,
|
|
48
|
+
) -> RunResult:
|
|
49
|
+
"""Parse et exécute une commande. Pour un protocole non-HTTP, l'adaptateur
|
|
50
|
+
correspondant doit être enregistré (register_default_adapters()).
|
|
51
|
+
"""
|
|
52
|
+
parsed = parse_command(command, variables)
|
|
53
|
+
if parsed.kind == "http":
|
|
54
|
+
if not parsed.requests:
|
|
55
|
+
raise RuntimeError("Aucune requête HTTP à exécuter.")
|
|
56
|
+
return RunResult(kind="http", http=execute(parsed.requests[0], file_reader))
|
|
57
|
+
if parsed.request is None:
|
|
58
|
+
blocker = next((w for w in parsed.warnings if w.level == "error"), None)
|
|
59
|
+
raise RuntimeError(blocker.message if blocker else f'Commande "{parsed.scheme}://" non exécutable.')
|
|
60
|
+
ctx = ProtocolContext(read_file=file_reader, write_file=write_file, client_factory=client_factory)
|
|
61
|
+
return RunResult(kind="protocol", protocol=execute_protocol(parsed.request, ctx))
|
urban_http/cookies.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Lecture des fichiers de cookies au format Netscape (celui de curl -b/-c),
|
|
2
|
+
avec filtrage domaine / chemin / expiration / secure comme curl.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import List
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CookieEntry:
|
|
14
|
+
domain: str
|
|
15
|
+
include_subdomains: bool
|
|
16
|
+
path: str
|
|
17
|
+
secure: bool
|
|
18
|
+
expires: int # timestamp Unix en secondes ; 0 = cookie de session
|
|
19
|
+
name: str
|
|
20
|
+
value: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_netscape_cookies(text: str) -> List[CookieEntry]:
|
|
24
|
+
entries: List[CookieEntry] = []
|
|
25
|
+
for line in text.splitlines():
|
|
26
|
+
if not line.strip():
|
|
27
|
+
continue
|
|
28
|
+
http_only = line.startswith("#HttpOnly_")
|
|
29
|
+
if http_only:
|
|
30
|
+
line = line[len("#HttpOnly_") :]
|
|
31
|
+
elif line.startswith("#"):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
fields = line.split("\t")
|
|
35
|
+
if len(fields) >= 7:
|
|
36
|
+
domain, flag, path, secure, expires, name = fields[:6]
|
|
37
|
+
value = "\t".join(fields[6:])
|
|
38
|
+
entries.append(
|
|
39
|
+
CookieEntry(
|
|
40
|
+
domain=domain.lstrip("."),
|
|
41
|
+
include_subdomains=domain.startswith(".") or flag.upper() == "TRUE",
|
|
42
|
+
path=path or "/",
|
|
43
|
+
secure=secure.upper() == "TRUE",
|
|
44
|
+
expires=_to_int(expires),
|
|
45
|
+
name=name,
|
|
46
|
+
value=value,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
elif len(fields) == 1 and "=" in line and " " not in line:
|
|
50
|
+
eq = line.index("=")
|
|
51
|
+
entries.append(
|
|
52
|
+
CookieEntry(
|
|
53
|
+
domain="",
|
|
54
|
+
include_subdomains=True,
|
|
55
|
+
path="/",
|
|
56
|
+
secure=False,
|
|
57
|
+
expires=0,
|
|
58
|
+
name=line[:eq],
|
|
59
|
+
value=line[eq + 1 :],
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return entries
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cookies_for_url(entries: List[CookieEntry], url: str, now: float = None) -> str:
|
|
66
|
+
"""Cookies applicables à une URL, au format "nom=valeur; ...". now en secondes."""
|
|
67
|
+
if now is None:
|
|
68
|
+
now = time.time()
|
|
69
|
+
try:
|
|
70
|
+
parts = urlsplit(url)
|
|
71
|
+
host = (parts.hostname or "").lower()
|
|
72
|
+
path = parts.path or "/"
|
|
73
|
+
is_https = parts.scheme == "https"
|
|
74
|
+
except ValueError:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
selected: List[str] = []
|
|
78
|
+
for e in entries:
|
|
79
|
+
if e.expires != 0 and e.expires < now:
|
|
80
|
+
continue
|
|
81
|
+
if e.secure and not is_https:
|
|
82
|
+
continue
|
|
83
|
+
if e.domain:
|
|
84
|
+
domain = e.domain.lower()
|
|
85
|
+
matches = host == domain or (e.include_subdomains and host.endswith("." + domain))
|
|
86
|
+
if not matches:
|
|
87
|
+
continue
|
|
88
|
+
if e.path and e.path != "/":
|
|
89
|
+
prefix = e.path if e.path.endswith("/") else e.path + "/"
|
|
90
|
+
if not (path == e.path or path.startswith(prefix)):
|
|
91
|
+
continue
|
|
92
|
+
if e.name:
|
|
93
|
+
selected.append(f"{e.name}={e.value}")
|
|
94
|
+
return "; ".join(selected)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _to_int(s: str) -> int:
|
|
98
|
+
try:
|
|
99
|
+
return int(s)
|
|
100
|
+
except (ValueError, TypeError):
|
|
101
|
+
return 0
|
urban_http/digest.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Authentification HTTP Digest (RFC 2617 / RFC 7616) :
|
|
2
|
+
parsing du challenge WWW-Authenticate et construction de l'en-tête Authorization.
|
|
3
|
+
|
|
4
|
+
Algorithmes : MD5, MD5-sess, SHA-256, SHA-256-sess, avec qop="auth" ou sans qop
|
|
5
|
+
(RFC 2069). qop="auth-int" n'est pas pris en charge.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, Dict, Optional
|
|
14
|
+
|
|
15
|
+
_PARAM = re.compile(r'([a-zA-Z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DigestChallenge:
|
|
20
|
+
realm: str
|
|
21
|
+
nonce: str
|
|
22
|
+
qop: Optional[str] = None
|
|
23
|
+
opaque: Optional[str] = None
|
|
24
|
+
algorithm: Optional[str] = None
|
|
25
|
+
stale: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_digest_challenge(header: str) -> Optional[DigestChallenge]:
|
|
29
|
+
idx = re.search(r"(^|[,\s])digest\s", header, re.IGNORECASE)
|
|
30
|
+
if not idx:
|
|
31
|
+
return None
|
|
32
|
+
rest = header[header.lower().index("digest", idx.start()) + len("digest") :]
|
|
33
|
+
|
|
34
|
+
params: Dict[str, str] = {}
|
|
35
|
+
for m in _PARAM.finditer(rest):
|
|
36
|
+
key = m.group(1).lower()
|
|
37
|
+
if m.group(2) is not None:
|
|
38
|
+
value = re.sub(r"\\(.)", r"\1", m.group(2))
|
|
39
|
+
else:
|
|
40
|
+
value = m.group(3) or ""
|
|
41
|
+
params[key] = value
|
|
42
|
+
|
|
43
|
+
if not params.get("realm") and not params.get("nonce"):
|
|
44
|
+
return None
|
|
45
|
+
return DigestChallenge(
|
|
46
|
+
realm=params.get("realm", ""),
|
|
47
|
+
nonce=params.get("nonce", ""),
|
|
48
|
+
qop=params.get("qop"),
|
|
49
|
+
opaque=params.get("opaque"),
|
|
50
|
+
algorithm=params.get("algorithm"),
|
|
51
|
+
stale=params.get("stale", "").lower() == "true",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_digest_authorization(
|
|
56
|
+
challenge: DigestChallenge,
|
|
57
|
+
username: str,
|
|
58
|
+
password: str,
|
|
59
|
+
method: str,
|
|
60
|
+
uri: str,
|
|
61
|
+
cnonce: Optional[str] = None,
|
|
62
|
+
nc: str = "00000001",
|
|
63
|
+
) -> Optional[str]:
|
|
64
|
+
algorithm = (challenge.algorithm or "MD5").upper()
|
|
65
|
+
sess = algorithm.endswith("-SESS")
|
|
66
|
+
base = algorithm[:-5] if sess else algorithm
|
|
67
|
+
|
|
68
|
+
if base == "MD5":
|
|
69
|
+
hash_fn: Callable[[str], str] = lambda s: hashlib.md5(s.encode()).hexdigest()
|
|
70
|
+
elif base == "SHA-256":
|
|
71
|
+
hash_fn = lambda s: hashlib.sha256(s.encode()).hexdigest()
|
|
72
|
+
else:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
qop: Optional[str] = None
|
|
76
|
+
if challenge.qop:
|
|
77
|
+
offered = [q.strip().lower() for q in challenge.qop.split(",")]
|
|
78
|
+
if "auth" in offered:
|
|
79
|
+
qop = "auth"
|
|
80
|
+
else:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if cnonce is None:
|
|
84
|
+
cnonce = secrets.token_hex(16)
|
|
85
|
+
|
|
86
|
+
ha1 = hash_fn(f"{username}:{challenge.realm}:{password}")
|
|
87
|
+
if sess:
|
|
88
|
+
ha1 = hash_fn(f"{ha1}:{challenge.nonce}:{cnonce}")
|
|
89
|
+
ha2 = hash_fn(f"{method.upper()}:{uri}")
|
|
90
|
+
|
|
91
|
+
if qop:
|
|
92
|
+
response = hash_fn(f"{ha1}:{challenge.nonce}:{nc}:{cnonce}:{qop}:{ha2}")
|
|
93
|
+
else:
|
|
94
|
+
response = hash_fn(f"{ha1}:{challenge.nonce}:{ha2}")
|
|
95
|
+
|
|
96
|
+
parts = [
|
|
97
|
+
f'username="{_escape(username)}"',
|
|
98
|
+
f'realm="{_escape(challenge.realm)}"',
|
|
99
|
+
f'nonce="{_escape(challenge.nonce)}"',
|
|
100
|
+
f'uri="{uri}"',
|
|
101
|
+
]
|
|
102
|
+
if challenge.algorithm:
|
|
103
|
+
parts.append(f"algorithm={challenge.algorithm}")
|
|
104
|
+
if qop:
|
|
105
|
+
parts += [f"qop={qop}", f"nc={nc}", f'cnonce="{cnonce}"']
|
|
106
|
+
parts.append(f'response="{response}"')
|
|
107
|
+
if challenge.opaque:
|
|
108
|
+
parts.append(f'opaque="{_escape(challenge.opaque)}"')
|
|
109
|
+
return "Digest " + ", ".join(parts)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _escape(s: str) -> str:
|
|
113
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|