Python: urllib3 で HTTP 認証 (Basic/Digest) する

urllib や requests では認証用のクラスを指定してフックするだけで HTTP 認証をおこなうことができるが、urllib3 には HTTP 認証機能がないため、自前で実装してみた。

## 認証シーケンス

認証なしでリクエストすると WWW-Authenticate ヘッダを含む 401 応答が返ってくるので、それを元に Authorization ヘッダをつけて再度要求する。Digest 認証の場合は WWW-Authenticate ヘッダに含まれる情報を元に計算したものを返す必要があるが、Basic 認証の場合はいきなり Authorization ヘッダをつけて投げても良い。

![](https://hirose-public.s3-ap-northeast-1.amazonaws.com/qiita/2022/08/06/de74ab2d-9cfa-4d9e-b61b-70a50702863b.png)

## 実装例

### Basic 認証

直接 Authorization ヘッダをつけてリクエストする場合

```python
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 ヘッダをつけてリトライ

```python
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
            })
```