作って学ぶリバースプロキシ⑧Digest認証の実装

## これは何?

https://qiita.com/sigma_devsecops/items/f4f3fbd2138b2b67d8ca

前回記事でRFCベースでDigest認証について学んだので今回実際に動くDigest認証を作ってみました。

[GitHub Repository](https://github.com/RyosukeDTomita/GateKeeper)

---

## 基本方針

https://qiita.com/sigma_devsecops/items/7726c643b5c65f654d9a

https://qiita.com/sigma_devsecops/items/058bf554af0cb25fb9e4

このへんでだいたいの骨格は作ってあったのでbasic認証に機能を足す形でコードを書いていきました。

---

## 全体の流れ

1. ブラウザからlocalhost/digestにアクセスする
2. 認証を行う
3. digest認証のAuthorizationヘッダからパラメータを抽出
4. redisから正解のパスワードを抽出
5. Authorizationヘッダから抽出したパラメータをもとにresponse値を再作成して比較する

```lua
local _M = {}
local resty_redis = require "resty.redis"
local redis = resty_redis:new()
local resty_md5 = require "resty.md5"
local str = require "resty.string"
local resty_random = require "resty.random"
-- NOTE: attempt to call global 'create_nonce' (a nil value)が出ることがあるので先に宣言しておく
local create_nonce
local md5_hash
local create_www_authenticate


-- nonceを生成する関数
local function create_nonce()
    -- NOTE: ETagの代わりに乱数を使う
    local random_bytes = resty_random.bytes
    local nonce = ngx.time() .. ":" .. random_bytes(3) .. ":" .. ngx.var.secret_data
    return nonce
end


local function create_www_authenticate()
    local nonce = create_nonce()
    return 'Digest realm="' .. ngx.var.host .. '/digest Restricted", qop="auth", nonce="' .. nonce .. 'algorithm=MD5"'
end


-- Digest認証のポップアップを出す。
local function is_authorization_header()
    if not ngx.var.http_authorization then
        local nonce = create_nonce()
        --ngx.header["WWW-Authenticate"] = 'Digest realm="' .. ngx.var.host .. '/digest Restricted", qop="auth", nonce="' .. nonce .. 'algorithm=MD5"'
        ngx.header["WWW-Authenticate"] = create_www_authenticate()
        ngx.log(ngx.INFO, "WWW-Authenticate: ", ngx.header["WWW-Authenticate"])
        ngx.exit(ngx.HTTP_UNAUTHORIZED)
    end
end


-- Authorizationヘッダをパースしてユーザ名、パスワードのハッシュ、nonceを取得
local function parse_authorization_header()
    local authorization = ngx.var.http_authorization
    ngx.log(ngx.INFO, "Authorization: ", authorization)

    local auth_params = {}
    -- NOTE: qop, ncの値が""で囲まれていないので，後から取得する
    for k, v in string.gmatch(authorization, '(%w+)="([^"]+)"') do
        auth_params[k] = v
    end
    
    local username = auth_params.username
    local realm = auth_params.realm
    local nonce = auth_params.nonce
    local uri = auth_params.uri
    local response = auth_params.response
    local qop = authorization:match('qop=([^,]+)')
    local nc = authorization:match('nc=([^,]+)')
    local cnonce = auth_params.cnonce

    --ngx.log(ngx.INFO, "Parsed Authorization - username: ", username, ", realm: ", realm, ", nonce: ", nonce, ", uri: ", uri, ", response: ", response, ", qop: ", qop, ", nc: ", nc, ", cnonce: ", cnonce)
    return username, realm, nonce, uri, response, qop, nc, cnonce
end


-- redisからユーザIDに対応するパスワードを取得
local function get_password_hash(user_id)
    -- redisに接続。 compose.yamlのサービス名で名前解決できる
    local ok, err = redis:connect("redis_app", 6379)
    if not ok then
        ngx.log(ngx.ERR, "failed to connect to redis: ", err)
        return nil
    end

    local res, err = redis:get("USER|" .. user_id)
    if not res then
        ngx.log(ngx.ERR, "failed to get password: ", err)
        return nil
    end
    return res
end


-- MD5ハッシュを計算する関数
local function md5_hash(data)
    local md5 = resty_md5:new()
    md5:update(data)
    return str.to_hex(md5:final())
end


function _M.auth()
    is_authorization_header()

    local username, realm, nonce, uri, response, qop, nc, cnonce = parse_authorization_header()
    ngx.log(ngx.INFO, "TRYING TO LOGIN: ", username)
    local password = get_password_hash(username)

    -- HA1 = MD5(username:realm:password)
    local ha1 = md5_hash(username .. ":" .. realm .. ":" .. password)
    -- HA2 = MD5(method:digestURI)
    local ha2 = md5_hash(ngx.req.get_method() .. ":" .. uri)
    -- response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
    local expected_response = md5_hash(ha1 .. ":" .. nonce .. ":" .. nc .. ":" .. cnonce .. ":" .. qop .. ":" .. ha2)

    if response == expected_response then
        ngx.log(ngx.INFO, "LOGIN SUCCESS: ", username)
        return --NOTE: ngx.exit(ngx.HTTP_OK)を返すと，後続のコンテンツが表示されない
    else
        --認証失敗時には再度Digest認証のポップアップを出す
        ngx.log(ngx.INFO, "LOGIN FAILED: ", username)
        local nonce = create_nonce()
        ngx.header["WWW-Authenticate"] = create_www_authenticate()
        ngx.exit(ngx.HTTP_UNAUTHORIZED)
    end
end

return _M

```

---

## 詰まったところ

### qopとncのパースがうまくいかない

Authorizationヘッダを眺めるとわかるのですが，他の値はKey="value"という形式になっています。
しかし，qopとncのみkey=valueという形式になっており""がついておりません。

```
Authorization: Digest username="user", realm="localhost/digest Restricted", nonce="1734797404:	P$:ZGMF-X42Salgorithm=MD5", uri="/digest", response="6b9bc786aa528fa46be66e78cc9098b9", qop=auth, nc=00000002, cnonce="815b61ceed591c3a"
```

そのため，自分は正規表現でパースしていたのですが，qopとncのみnilになる現象に悩まされていました。
しょうがないのでqopとncのみ個別に取得する形にしました(正規表現で一発でかけそうではありますが，様の勉強という意味ではこっちのほうが後から見返した時に良いなと思ったのとめんどくさかったので一旦これで)

```lua
    local auth_params = {}
    -- NOTE: qop, ncの値が""で囲まれていないので，後から取得する
    for k, v in string.gmatch(authorization, '(%w+)="([^"]+)"') do
        auth_params[k] = v
    end
    
    local username = auth_params.username
    local realm = auth_params.realm
    local nonce = auth_params.nonce
    local uri = auth_params.uri
    local response = auth_params.response
    local qop = authorization:match('qop=([^,]+)')
    local nc = authorization:match('nc=([^,]+)')
    local cnonce = auth_params.cnonce
```

### response計算部分

これは[RFC7616](https://datatracker.ietf.org/doc/html/rfc7616#section-3.3)の書き方に惑わされた話です。

>          response = <"> < KD ( H(A1), unq(nonce)
>                                      ":" nc
>                                      ":" unq(cnonce)
>                                      ":" unq(qop)
>                                      ":" H(A2)
>                              ) <">

普通にH(A1)とunq(nonce)の結合文字列だけ`:`ではなく，`,`なのかと勘違いしてました。

https://github.com/openresty/lua-resty-string/tree/master

先駆者様のコードをみて文字列の結合は全部`:`でいいことがわかりました。

### ETagが使えなかった

nginx.confで`etag on;`すればつくのですが，リバースプロキシの転送先にはうまくつきませんでした。
`ngx.arg[1]`からハッシュを計算して代用しようかと思ったりしたのですが，ngx.arg[1]は`access_by_lua_file`からは使えないと言われたので雑に乱数で代用しました。

別にRFC7616にnonceの作り方は決まっていないのであまり気にはしていないのですが，exampleと同じものを作りたかったのでちょっと不完全燃焼感はあります。

>  A nonce
>      might, for example, be constructed as the Base64 encoding of
>           timestamp H(timestamp ":" ETag ":" secret-data)

### ユーザの推測が可能になってしまった

存在しないユーザを指定した場合，redisからのレスポンスがstring型でなくuserdata型になってしまったので修正

```lua
-- redisからユーザIDに対応するパスワードを取得
local function get_password_hash(user_id)
    -- redisに接続。 compose.yamlのサービス名で名前解決できる
    local ok, err = redis:connect("redis_app", 6379)
    if not ok then
        ngx.log(ngx.ERR, "failed to connect to redis: ", err)
        return nil
    end

    local res, err = redis:get("USER|" .. user_id)
    if not res then
        ngx.log(ngx.ERR, "failed to get password: ", err)
        return nil
    end
    return res
end
```

---

## 次回

Form認証をやりたいです。
アドカレは完走したので記事は保守するかもしれませんが，しばらく間は空きそうです。