urllib や requests では認証用のクラスを指定してフックするだけで HTTP 認証をおこなうことができるが、urllib3 には HTTP 認証機能がないため、自前で実装してみた。
認証シーケンス
認証なしでリクエストすると WWW-Authenticate ヘッダを含む 401 応答が返ってくるので、それを元に Authorization ヘッダをつけて再度要求する。Digest 認証の場合は WWW-Authenticate ヘッダに含まれる情報を元に計算したものを返す必要があるが、Basic 認証の場合はいきなり Authorization ヘッダをつけて投げても良い。
実装例
Basic 認証
直接 Authorization ヘッダをつけてリクエストする場合
class HTTPBasicAuth:
@staticmethod
def build_authorization(username: str, password: str):
return "Basic " + base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8")
def get(endpoint: str, username: str, password: str):
http = urllib3.PoolManager()
res = http.request("GET", endpoint, headers={
"Authorization": HTTPBasicAuth.build_authorization(username, password),
})
Basic/Digest 認証
WWW-Authenticate が返ってきたら Authorization ヘッダをつけてリトライ
class HTTPDigestAuth:
def __init__(self):
self.cnonce = "".join([random.choice('0123456789abcdef') for x in range(32)])
self.nc: int = 0
@staticmethod
def parse_www_authenticate(www_authenticate: str) -> Optional[dict]:
if www_authenticate[:6].lower() != "digest":
return None
result = {}
for param in re.split(r",\s*", www_authenticate[7:]):
(k, v) = param.split("=", 2)
if v[0] == "\"" and v[-1] == "\"":
v = v[1:-1]
result[k] = v
return result
@staticmethod
def calculate_response(realm: str, nonce: str, qop: str, cnonce: str, nc: int, method: str, uri: str, username: str, password: str) -> str:
def md5hex(a: str):
return md5(a.encode("utf-8")).hexdigest()
a1 = f"{username}:{realm}:{password}"
a2 = f"{method}:{uri}"
a3 = f"{md5hex(a1)}:{nonce}:{nc:08d}:{cnonce}:{qop}:{md5hex(a2)}"
return md5hex(a3)
def build_authorization(self, method: str, uri: str, www_authenticate: str, username: str, password: str) -> Optional[str]:
www_authenticate = self.parse_www_authenticate(www_authenticate)
if not www_authenticate:
return None
if www_authenticate["algorithm"].lower() != "md5" or www_authenticate["qop"] != "auth":
return None # unsupported
self.nc += 1
response = self.calculate_response(
www_authenticate["realm"], www_authenticate["nonce"], www_authenticate["qop"],
self.cnonce, self.nc, method, uri, username, password
)
return \
"Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\"" \
", algorithm={}, qop={}, cnonce=\"{}\", nc={:08d}, response=\"{}\"".format(
username,
www_authenticate["realm"],
www_authenticate["nonce"],
uri,
www_authenticate["algorithm"],
www_authenticate["qop"],
self.cnonce,
self.nc,
response
)
def get(base_url: str, path: str, username: str, password: str):
http = urllib3.PoolManager()
endpoint = f"{base_url}{path}"
res = http.request("GET", endpoint)
if res.status == 401 and "WWW-Authenticate" in res.headers:
www_authenticate_header = res.headers["WWW-Authenticate"]
authorization = None
www_authenticate_basic = www_authenticate_header.find("Basic ")
www_authenticate_digest = www_authenticate_header.find("Digest ")
if www_authenticate_basic >= 0 and AUTH_TYPE == "basic":
# Basic 認証
authorization = HTTPBasicAuth.build_authorization(
username, password
)
elif www_authenticate_digest >= 0 and AUTH_TYPE == "digest":
# Digest 認証
authorization = digest_auth.build_authorization(
"GET", PATH, www_authenticate_header[www_authenticate_digest:], username, password
)
if authorization:
res = http.request("GET", endpoint, headers={
"Authorization": authorization
})