1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

セキュリティごった煮一人完走チャレンジAdvent Calendar 2024

Day 25

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

Last updated at Posted at 2024-12-21

これは何?

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

GitHub Repository


基本方針

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


全体の流れ

  1. ブラウザからlocalhost/digestにアクセスする
  2. 認証を行う
  3. digest認証のAuthorizationヘッダからパラメータを抽出
  4. redisから正解のパスワードを抽出
  5. Authorizationヘッダから抽出したパラメータをもとにresponse値を再作成して比較する
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のみ個別に取得する形にしました(正規表現で一発でかけそうではありますが,様の勉強という意味ではこっちのほうが後から見返した時に良いなと思ったのとめんどくさかったので一旦これで)

    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の書き方に惑わされた話です。

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

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

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

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型になってしまったので修正

-- 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認証をやりたいです。
アドカレは完走したので記事は保守するかもしれませんが,しばらく間は空きそうです。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?