これは何?
前回記事でRFCベースでDigest認証について学んだので今回実際に動くDigest認証を作ってみました。
基本方針
このへんでだいたいの骨格は作ってあったのでbasic認証に機能を足す形でコードを書いていきました。
全体の流れ
- ブラウザからlocalhost/digestにアクセスする
- 認証を行う
- digest認証のAuthorizationヘッダからパラメータを抽出
- redisから正解のパスワードを抽出
- 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認証をやりたいです。
アドカレは完走したので記事は保守するかもしれませんが,しばらく間は空きそうです。