Per-host HTTP basic auth (#57)

Adds opt-in basic auth per Host, following the existing per-host controls
pattern:
- Host fields basicauth_enabled / basicauth_realm / basicauth_users
  ({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure
  host_features normalizer and hashed at the route layer (utils/basicauth.js),
  so plaintext never reaches Redis.
- ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the
  Authorization header against base64(sha1(password)), fails closed with a 401
  WWW-Authenticate challenge.
- hosts.ejs gains an enable toggle, realm, and a username:password textarea
  (passwords never echoed back; blank keeps the current set).

Unit tests cover hashing (matches the htpasswd {SHA} vector), credential
parsing, and normalization. Note: the Lua path needs verification on a live
OpenResty box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:47:08 -04:00
parent d1586b4d5a
commit 3e5590288a
8 changed files with 288 additions and 4 deletions
+44
View File
@@ -10,6 +10,8 @@
-- req_headers (JSON object) -- added to the upstream request
-- resp_headers (JSON object) -- added to the client response
-- ip_allow / ip_deny (JSON arrays of CIDRs)
-- basicauth_enabled / basicauth_realm
-- basicauth_users (JSON object {username: base64(sha1(password))})
local cjson = require "cjson.safe"
@@ -80,6 +82,47 @@ local function apply_ratelimit(res, host, ip)
end
end
-- Per-host HTTP basic auth. Credentials are stored as {user: base64(sha1(pw))}
-- (htpasswd "{SHA}" scheme; hashed server-side in nodejs). Fails closed: any
-- misconfig or bad credential returns 401 with a WWW-Authenticate challenge.
local function apply_basicauth(res)
if res["basicauth_enabled"] ~= "true" then return end
local realm = res["basicauth_realm"]
if not realm or realm == "" then realm = "Restricted" end
realm = realm:gsub('[\r\n"]', "") -- defense in depth for the header
local function deny()
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
return ngx.exit(401)
end
local users = decode_table(res["basicauth_users"])
if not users then return deny() end -- enabled but no users -> deny all
local header = ngx.var.http_authorization
if not header then return deny() end
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
if not b64 then return deny() end
local decoded = ngx.decode_base64(b64)
if not decoded then return deny() end
local user, pass = decoded:match("^([^:]*):(.*)$")
if not user or user == "" then return deny() end
local stored = users[user]
if not stored then return deny() end
local sha1 = require "resty.sha1"
local hasher = sha1:new()
if not hasher then return deny() end
hasher:update(pass or "")
local computed = ngx.encode_base64(hasher:final())
if computed ~= stored then return deny() end
-- authenticated: fall through to the rest of the request
end
-- Extra request headers sent to the upstream.
local function apply_req_headers(res)
local headers = decode_table(res["req_headers"])
@@ -97,6 +140,7 @@ function M.access(ngx_, res)
apply_ip_access(res, ip)
apply_ratelimit(res, host, ip)
apply_basicauth(res)
apply_req_headers(res)
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.