Per-host SSO: OpenResty gate + auth location (#57)
- hostfeatures.lua: replace basic-auth-only enforcement with a combined apply_auth() that allows if EITHER basic auth OR a valid SSO session passes. A "Basic" Authorization header takes the basic path (401 on failure); otherwise a browser is 302'd to /__proxy_auth/start. SSO sessions are read straight from Redis (proxy_SsoSession_<sid>, sid from the __proxy_sso cookie, character-restricted) and matched to the host. - proxy.conf: add a /__proxy_auth/ location (outside the gate) that forwards to the nodejs app so the OIDC flow can run and set the cookie on every host. - nginx.conf: add the proxy_auth_backend upstream (defaults to 127.0.0.1:3000). Needs live verification on an OpenResty box (no Lua/nginx runtime in CI here). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
||||
-- basicauth_enabled / basicauth_realm
|
||||
-- basicauth_users (JSON object {username: base64(sha1(password))})
|
||||
-- sso_enabled -- gate on a __proxy_sso session (established by /__proxy_auth)
|
||||
|
||||
local cjson = require "cjson.safe"
|
||||
|
||||
@@ -82,45 +83,113 @@ 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
|
||||
-- ---- Per-host authentication (basic auth OR SSO) -----------------------
|
||||
--
|
||||
-- Both are optional. If either is enabled, a request must satisfy at least one.
|
||||
-- A "Basic" Authorization header routes to the basic-auth path (401 challenge on
|
||||
-- failure); otherwise a browser is redirected into the SSO login. Basic-auth
|
||||
-- creds are stored as {user: base64(sha1(pw))} (htpasswd "{SHA}"; hashed in
|
||||
-- nodejs). SSO relies on a Redis-backed session established by /__proxy_auth
|
||||
-- (nodejs); the allow-list was enforced there, so here we only confirm a valid
|
||||
-- session for this host.
|
||||
|
||||
-- True when the request carries valid basic-auth credentials for this host.
|
||||
local function basic_auth_ok(res)
|
||||
local users = decode_table(res["basicauth_users"])
|
||||
if not users then return deny() end -- enabled but no users -> deny all
|
||||
if not users then return false end
|
||||
|
||||
local header = ngx.var.http_authorization
|
||||
if not header then return deny() end
|
||||
if not header then return false end
|
||||
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
|
||||
if not b64 then return deny() end
|
||||
if not b64 then return false end
|
||||
|
||||
local decoded = ngx.decode_base64(b64)
|
||||
if not decoded then return deny() end
|
||||
if not decoded then return false end
|
||||
local user, pass = decoded:match("^([^:]*):(.*)$")
|
||||
if not user or user == "" then return deny() end
|
||||
if not user or user == "" then return false end
|
||||
|
||||
local stored = users[user]
|
||||
if not stored then return deny() end
|
||||
if not stored then return false end
|
||||
|
||||
local sha1 = require "resty.sha1"
|
||||
local hasher = sha1:new()
|
||||
if not hasher then return deny() end
|
||||
if not hasher then return false end
|
||||
hasher:update(pass or "")
|
||||
local computed = ngx.encode_base64(hasher:final())
|
||||
return ngx.encode_base64(hasher:final()) == stored
|
||||
end
|
||||
|
||||
if computed ~= stored then return deny() end
|
||||
-- authenticated: fall through to the rest of the request
|
||||
local function basic_challenge(res)
|
||||
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
|
||||
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
|
||||
return ngx.exit(401)
|
||||
end
|
||||
|
||||
-- Read an SSO session hash from Redis. Returns the table or nil. The sid comes
|
||||
-- from a cookie (attacker-controlled), so it is character-restricted before use.
|
||||
local function sso_get_session(sid)
|
||||
if not sid or not sid:match("^[%w_%-]+$") then return nil end
|
||||
|
||||
local redis = require "resty.redis"
|
||||
local red = redis:new()
|
||||
red:set_timeout(1000)
|
||||
local ok, err = red:connect("127.0.0.1", 6379)
|
||||
if not ok then
|
||||
ngx.log(ngx.ERR, "hostfeatures: sso redis connect ", err)
|
||||
return nil
|
||||
end
|
||||
|
||||
local arr = red:hgetall("proxy_SsoSession_" .. sid)
|
||||
local sess = arr and red:array_to_hash(arr) or nil
|
||||
red:set_keepalive(10000, 100)
|
||||
|
||||
if sess and next(sess) ~= nil then return sess end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- True when a valid SSO session cookie exists for THIS host. (The session
|
||||
-- auto-expires via Redis TTL; a missing key reads as no session.)
|
||||
local function sso_session_ok()
|
||||
local sid = ngx.var.cookie___proxy_sso
|
||||
if not sid or sid == "" then return false end
|
||||
local sess = sso_get_session(sid)
|
||||
if not sess or not sess["sub"] then return false end
|
||||
if sess["host"] ~= ngx.var.host then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Send a browser into the SSO login, preserving where it was headed. Non-idempotent
|
||||
-- methods get a 401 instead of a redirect they couldn't safely replay.
|
||||
local function sso_redirect()
|
||||
local m = ngx.req.get_method()
|
||||
if m ~= "GET" and m ~= "HEAD" then
|
||||
return ngx.exit(401)
|
||||
end
|
||||
local rd = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri
|
||||
return ngx.redirect("/__proxy_auth/start?rd=" .. ngx.escape_uri(rd), 302)
|
||||
end
|
||||
|
||||
-- Enforce whichever auth methods are enabled; allow if EITHER passes.
|
||||
local function apply_auth(res)
|
||||
local basic_on = res["basicauth_enabled"] == "true"
|
||||
local sso_on = res["sso_enabled"] == "true"
|
||||
if not basic_on and not sso_on then return end
|
||||
|
||||
if basic_on and basic_auth_ok(res) then return end
|
||||
if sso_on and sso_session_ok() then return end
|
||||
|
||||
-- Not authenticated. Pick the right challenge for the client.
|
||||
local auth = ngx.var.http_authorization
|
||||
local has_basic_header = auth and auth:match("^%s*[Bb]asic%s") ~= nil
|
||||
|
||||
if sso_on and not has_basic_header then
|
||||
return sso_redirect()
|
||||
end
|
||||
if basic_on then
|
||||
return basic_challenge(res)
|
||||
end
|
||||
return sso_redirect()
|
||||
end
|
||||
|
||||
-- Extra request headers sent to the upstream.
|
||||
@@ -140,7 +209,7 @@ function M.access(ngx_, res)
|
||||
|
||||
apply_ip_access(res, ip)
|
||||
apply_ratelimit(res, host, ip)
|
||||
apply_basicauth(res)
|
||||
apply_auth(res)
|
||||
apply_req_headers(res)
|
||||
|
||||
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
||||
|
||||
@@ -30,6 +30,13 @@ http {
|
||||
|
||||
resolver 8.8.4.4 8.8.8.8;
|
||||
|
||||
# Backend for per-host SSO endpoints (/__proxy_auth, see proxy.conf). Point
|
||||
# this at the nodejs app that serves the admin UI. Default assumes it is
|
||||
# colocated on this box; change the address for a split deployment.
|
||||
upstream proxy_auth_backend {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
init_by_lua_block {
|
||||
|
||||
auto_ssl = (require "resty.auto-ssl").new()
|
||||
|
||||
@@ -12,6 +12,19 @@ server {
|
||||
real_ip_header X-Real-IP;
|
||||
real_ip_recursive on;
|
||||
|
||||
# Per-host SSO endpoints (#57), served on EVERY proxied host by the nodejs app.
|
||||
# This location deliberately sits OUTSIDE the auth gate in `location /` (so the
|
||||
# login flow itself is never gated) and forwards to the app, which runs the
|
||||
# OIDC flow and sets the __proxy_sso session cookie for this host.
|
||||
location /__proxy_auth/ {
|
||||
proxy_pass http://proxy_auth_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
set $target '';
|
||||
|
||||
Reference in New Issue
Block a user