diff --git a/README.md b/README.md index d3e5bc0..0704c5d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ local saml = resty_saml.new(opts) | `sp_acs_url` | string | built from the request | Absolute URL of this SP's assertion consumer service. It is announced to the IdP, every `SubjectConfirmationData/@Recipient` has to name it, and a `Destination` has to name it on a response carrying one. Unset, it is assembled from the request's scheme and host, which is only as trustworthy as whatever sits in front: set it wherever the ingress does not normalise `Forwarded` and `X-Forwarded-*`, or terminates TLS without setting `X-Forwarded-Proto`. | | `sp_audiences` | array of strings | `{ sp_issuer }` | Audiences this SP answers to. An assertion carrying an `AudienceRestriction` has to name one of them; an assertion carrying none is unrestricted. | | `clock_skew` | number | `60` | Seconds of clock difference tolerated against the IdP when weighing `NotBefore` and `NotOnOrAfter`. | +| `replay_dict` | string | None | Name of an `lua_shared_dict` in which to remember the assertions already presented, so none is accepted twice. Unset leaves them untracked. | +| `replay_ttl` | number | `600` | Seconds to remember an assertion that names no `NotOnOrAfter` of its own. One that names it is remembered until it expires. | #### Binding a response to the request diff --git a/lua/resty/saml.lua b/lua/resty/saml.lua index a7f92d8..71c642d 100644 --- a/lua/resty/saml.lua +++ b/lua/resty/saml.lua @@ -320,6 +320,9 @@ end -- what stops an assertion minted for another SP in the same federation. local DEFAULT_CLOCK_SKEW = 60 +-- how long an assertion that sets no expiry of its own is remembered +local DEFAULT_REPLAY_TTL = 600 + local function time_bounds_ok(not_before, not_on_or_after, now, skew) local opens, closes, err @@ -501,6 +504,52 @@ local function issuers_allowed(allowed, issuers) return true end +-- A bearer assertion is good for one login. Nothing above stops the same one +-- being presented again inside its validity window, so its ID is kept until it +-- expires and a second presentation is refused. +-- +-- The window from the assertion's own Conditions decides how long the entry +-- lives, so the cache holds exactly what is still usable. An assertion that +-- names no expiry is replayable for as long as it is remembered, which is what +-- replay_ttl bounds. +local function assertions_unused(dict, opts, assertions, now) + local skew = opts.clock_skew or DEFAULT_CLOCK_SKEW + + for _, assertion in ipairs(assertions) do + if not assertion.id then + return false, "an assertion without an ID cannot be tracked" + end + + local ttl = opts.replay_ttl or DEFAULT_REPLAY_TTL + if assertion.not_on_or_after then + local expires = parse_iso8601_utc_time(assertion.not_on_or_after) + if expires then + ttl = expires + skew - now + end + end + if ttl < 1 then + ttl = 1 + end + + -- an SP name in the key so instances sharing one dict stay apart + local key = tostring(opts.sp_issuer) .. "|" .. assertion.id + local added, err, forcible = dict:add(key, true, ttl) + if not added then + if err == "exists" then + return false, "assertion " .. assertion.id .. " has been presented already" + end + return false, "could not track assertion " .. assertion.id .. ": " .. tostring(err) + end + if forcible then + ngx.log(ngx.WARN, "the assertion replay dict is full, older assertions are ", + "no longer tracked") + end + end + + return true +end + + local function login_callback(self, opts) local sess = session.start(self.session_config) @@ -584,12 +633,21 @@ local function login_callback(self, opts) ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end - local acceptable, reason = assertions_acceptable(opts, assertions, expected, ngx.time()) + local now = ngx.time() + local acceptable, reason = assertions_acceptable(opts, assertions, expected, now) if not acceptable then ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(reason)) ngx.exit(ngx.HTTP_UNAUTHORIZED) end + if self.replay_dict then + local unused, used_reason = assertions_unused(self.replay_dict, opts, assertions, now) + if not unused then + ngx.log(ngx.ERR, "response from IdP rejected: ", loggable(used_reason)) + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end + end + local issuer = saml.doc_issuer(doc) local attrs = saml.doc_attrs(doc) local name_id = saml.doc_name_id(doc) @@ -800,6 +858,10 @@ function _M.new(opts) obj.idp_cert_func = function(doc) return idp_cert end obj.auth_protocol_binding_method = opts.auth_protocol_binding_method obj.idp_issuers = issuer_set(opts.idp_issuers) + if opts.replay_dict then + obj.replay_dict = assert(ngx.shared[opts.replay_dict], + "no lua_shared_dict named " .. opts.replay_dict) + end local cookie_secure, cookie_same_site if opts.auth_protocol_binding_method == "HTTP-POST" then cookie_secure = true diff --git a/t/assertion-conditions.t b/t/assertion-conditions.t index d730b32..633ecb2 100644 --- a/t/assertion-conditions.t +++ b/t/assertion-conditions.t @@ -35,6 +35,11 @@ _EOC_ lua_package_path '$pwd/lua/?.lua;$pwd/deps/share/lua/5.1/?.lua;$pwd/t/?.lua;;'; lua_package_cpath '$pwd/?.so;$pwd/deps/lib/lua/5.1/?.so;;'; + # blocks driving it flush it first: a zone of the same name and size is + # reused across a reload, so entries otherwise outlive the block that made + # them under TEST_NGINX_USE_HUP=1 + lua_shared_dict saml_replay 1m; + init_by_lua_block { saml = require "saml" local err = saml.init({ debug = true, data_dir = os.getenv("SAML_DATA_DIR") }) @@ -100,6 +105,7 @@ GnHKA3uj9HpsS6fAxHNPPvWxRjO67Xj8Yw== skew = { clock_skew = 300 }, audiences = { sp_audiences = { "https://sp.example.com/metadata" } }, acs = { sp_acs_url = "http://127.0.0.1:1984/acs" }, + replay = { replay_dict = "saml_replay" }, } SPS = {} @@ -986,3 +992,59 @@ offers no subject confirmation this SP can satisfy 302 http://127.0.0.1:1984/idp --- error_log session carries no request id, starting the login again + + +=== TEST 32: an assertion is good for one login +--- config + location /t { + content_by_lua_block { + ngx.shared.saml_replay:flush_all() + local xml = saml_response({ conditions = conditions({ not_on_or_after = at(600) }) }) + ngx.say(login_with("replay", xml)) + ngx.say(login_with("replay", xml)) + } + } +--- response_body +302 / +401 nil +--- error_log +assertion a1 has been presented already + + +=== TEST 33: a second assertion of its own is accepted +--- config + location /t { + content_by_lua_block { + ngx.shared.saml_replay:flush_all() + ngx.say(login_with("replay", saml_response({ id = "a1" }))) + ngx.say(login_with("replay", saml_response({ id = "a2" }))) + } + } +--- response_body +302 / +302 / + + +=== TEST 34: an assertion is remembered for as long as it is usable +--- config + location /t { + content_by_lua_block { + ngx.shared.saml_replay:flush_all() + ngx.say(login_with("replay", saml_response({ + conditions = conditions({ not_on_or_after = at(600) }), + }))) + -- the window plus the skew allowance, which is when it stops being + -- accepted and so stops being worth remembering + local ttl = ngx.shared.saml_replay:ttl("sp|a1") + ngx.say("tracked: ", ttl > 600 and ttl <= 660) + + ngx.say(login_with("replay", saml_response({ id = "a2" }))) + local default = ngx.shared.saml_replay:ttl("sp|a2") + ngx.say("default: ", default > 590 and default <= 600) + } + } +--- response_body +302 / +tracked: true +302 / +default: true