diff --git a/ops/nginx_conf/hostfeatures.lua b/ops/nginx_conf/hostfeatures.lua index a26b7c7..f2da364 100644 --- a/ops/nginx_conf/hostfeatures.lua +++ b/ops/nginx_conf/hostfeatures.lua @@ -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. diff --git a/ops/nginx_conf/nginx.conf b/ops/nginx_conf/nginx.conf index 52b2e00..53df15d 100644 --- a/ops/nginx_conf/nginx.conf +++ b/ops/nginx_conf/nginx.conf @@ -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() diff --git a/ops/nginx_conf/proxy.conf b/ops/nginx_conf/proxy.conf index 15648c1..53b7a2b 100644 --- a/ops/nginx_conf/proxy.conf +++ b/ops/nginx_conf/proxy.conf @@ -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 '';