diff --git a/README.md b/README.md index bac2f9fb..7c490598 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ http { * [session.start](#sessionstart) * [session.logout](#sessionlogout) * [session.destroy](#sessiondestroy) + * [session.revoke_subject](#sessionrevoke_subject) + * [session.revoke_sid](#sessionrevoke_sid) * [Instance Methods](#instance-methods) * [session:open](#sessionopen-1) * [session:save](#sessionsave) @@ -216,6 +218,8 @@ http { * [session:get_audience](#sessionget_audience) * [session:set_subject](#sessionset_subject) * [session:get_subject](#sessionget_subject) + * [session:set_sid](#sessionset_sid) + * [session:get_sid](#sessionget_sid) * [session:get_property](#sessionget_property) * [session:set_remember](#sessionset_remember) * [session:get_remember](#sessionget_remember) @@ -367,6 +371,27 @@ storage with a TTL equal to the remaining session lifetime (rolling and absolute timeouts). The revocation mark is a lightweight sentinel; no session payload is stored. +Sessions can also be revoked by their subject (`session:set_subject`) or by an +identity provider session id (`session:set_sid`, e.g. the OpenID Connect `sid` +claim). `session.revoke_subject(subject, ttl, configuration)` and +`session.revoke_sid(sid, ttl, configuration)` write a mark without an open +session; sessions carrying the value that were created at or before the mark +are rejected on `session:open`, later ones are not. `ttl` must cover the +sessions' absolute timeout (`remember_absolute_timeout` when remember cookies +are used). Marks are stored under a `subject:` or `sid:` prefix and go through +`hash_subject` and `hash_storage_key`, enable them when the values may contain +personal data. Both functions always return write errors; `revocation_fail_mode` +applies to `session:open` and `session:destroy` only. + +```lua +-- On login +session:set_subject(id_token.sub) +session:set_sid(id_token.sid) + +-- In a back-channel logout handler, without an open session +require("resty.session").revoke_sid(logout_token.sid, 86400) +``` + Use `revocation_fail_mode` to control behavior when the storage is unavailable: - `"open"` (default): log a warning and treat the session as not revoked. @@ -884,6 +909,40 @@ local ok, err, exists, destroyed = require "resty.session".destroy({ See [configuration](#configuration) for possible configuration settings. +### session.revoke_subject + +**syntax:** *ok, err = session.revoke_subject(subject, ttl, configuration)* + +It marks a subject (see `session:set_subject`) as revoked without an open +session; sessions carrying the subject that were created at or before the mark +are rejected on `session:open`. `ttl` (in seconds) must cover the sessions' +absolute timeout (`remember_absolute_timeout` when remember cookies are used). +Write errors are always returned. + +```lua +local ok, err = require "resty.session".revoke_subject("john@doe.com", 86400) +``` + +See [configuration](#configuration) for possible configuration settings. + + +### session.revoke_sid + +**syntax:** *ok, err = session.revoke_sid(sid, ttl, configuration)* + +It marks an identity provider session id (see `session:set_sid`) as revoked +without an open session; sessions carrying the sid that were created at or +before the mark are rejected on `session:open`. `ttl` (in seconds) must cover +the sessions' absolute timeout (`remember_absolute_timeout` when remember +cookies are used). Write errors are always returned. + +```lua +local ok, err = require "resty.session".revoke_sid(logout_token.sid, 86400) +``` + +See [configuration](#configuration) for possible configuration settings. + + ## Instance Methods ### session:open @@ -1117,6 +1176,33 @@ end ``` +### session:set_sid + +**syntax:** *session:set_sid(sid)* + +Set an identity provider session id (e.g. the OpenID Connect `sid` claim) so +that `session.revoke_sid` can revoke the session by it. + +```lua +local session = require "resty.session".new() +session:set_sid(id_token.sid) +``` + + +### session:get_sid + +**syntax:** *sid = session:get_sid()* + +Get the identity provider session id. + +```lua +local session, err, exists = require "resty.session".open() +if exists then + local sid = session:get_sid() +end +``` + + ### session:get_property **syntax:** *value = session:get_property(name)* diff --git a/lib/resty/session.lua b/lib/resty/session.lua index 2ad91093..73f0fe26 100644 --- a/lib/resty/session.lua +++ b/lib/resty/session.lua @@ -387,23 +387,18 @@ local function handle_revocation_error(self, err, msg) end -local function is_session_revoked(self, sid, cookie_name) - if self.storage or not sid then - return false, nil - end - +local function is_revoked(self, key, cookie_name, current_time, creation_time) local revocation = self.revocation if not revocation then return false, nil end - local key, herr = self.hash_storage_key(sid) - if not key then - return nil, herr + local storage_key, err = self.hash_storage_key(key) + if not storage_key then + return nil, err end - local current_time = time() - local data, err = revocation:get(cookie_name, key, current_time) + local mark, err = revocation:get(cookie_name, storage_key, current_time) if err then local ok, rerr = handle_revocation_error(self, err, "unable to check session revocation") if not ok then @@ -412,10 +407,37 @@ local function is_session_revoked(self, sid, cookie_name) return false, nil end - if data == REVOCATION_MARK then + if mark == REVOCATION_MARK then return true, nil end + local revoked_at = tonumber(mark) + return revoked_at ~= nil and revoked_at >= creation_time, nil +end + + +local function subject_revocation_key(self, subject) + return "subject:" .. self.hash_subject(subject) +end + + +local function sid_revocation_key(sid) + return "sid:" .. sid +end + + +local function is_revoked_by(self, subject, sid, current_time, creation_time) + if subject then + local revoked, err = is_revoked(self, subject_revocation_key(self, subject), self.cookie_name, current_time, creation_time) + if revoked or err then + return revoked, err + end + end + + if sid then + return is_revoked(self, sid_revocation_key(sid), self.cookie_name, current_time, creation_time) + end + return false, nil end @@ -795,7 +817,7 @@ local function open(self, remember, meta_only) end end - local revoked, err = is_session_revoked(self, sid, cookie_name) + local revoked, err = is_revoked(self, sid, cookie_name, current_time, creation_time) if err then return nil, err end @@ -945,9 +967,35 @@ local function open(self, remember, meta_only) local audience_index local count = #data for i = 1, count do - if data[i][2] == audience then + -- cjson decodes the JSON null of a missing subject or sid as userdata + if type(data[i][3]) == "userdata" then + data[i][3] = nil + end + if type(data[i][4]) == "userdata" then + data[i][4] = nil + end + + if not audience_index and data[i][2] == audience then audience_index = i - break + end + end + + if self.revocation then + for i = 1, count do + if remember or i == audience_index then + local revoked, err = is_revoked_by(self, data[i][3], data[i][4], current_time, creation_time) + if err then + return nil, err + end + if revoked then + if remember then + self.remember_meta = {} + else + self.meta = DUMMY_META + end + return nil, "session revoked" + end + end end end @@ -1942,6 +1990,42 @@ function metatable:get_subject() end +--- +-- Set session sid. +-- +-- An identity provider's session id (e.g. the OpenID Connect `sid` claim) +-- carried in the payload; `session.revoke_sid` revokes sessions by it. +-- +-- @function instance:set_sid +-- @tparam string|nil sid identity provider session id (`nil` clears it) +-- +-- @usage +-- local session = require("resty.session").new() +-- session:set_sid(id_token.sid) +function metatable:set_sid(sid) + assert(self.state ~= STATE_CLOSED, "unable to set sid on closed session") + assert(sid == nil or type(sid) == "string", "invalid sid") + self.data[self.data_index][4] = sid +end + + +--- +-- Get session sid. +-- +-- @function instance:get_sid +-- @treturn string|nil identity provider session id +-- +-- @usage +-- local session, err, exists = require("resty.session").open() +-- if exists then +-- local sid = session:get_sid() +-- end +function metatable:get_sid() + assert(self.state ~= STATE_CLOSED, "unable to get sid on closed session") + return self.data[self.data_index][4] +end + + --- -- Get session property. -- @@ -2925,6 +3009,79 @@ function session.destroy(configuration) end +local function revoke(self, key, ttl) + local revocation = self.revocation + if not revocation then + return nil, "session revocation is not enabled" + end + + local storage_key, err = self.hash_storage_key(key) + if not storage_key then + return nil, err + end + + local current_time = time() + local ok, err = revocation:set(self.cookie_name, storage_key, tostring(current_time), ttl, current_time) + if not ok then + return nil, errmsg(err, "unable to revoke session key") + end + + return true +end + + +--- +-- Revoke sessions by subject. +-- +-- Writes a mark for a subject (see `session:set_subject`); sessions carrying +-- it that were created at or before now are rejected on open. `ttl` must +-- cover the sessions' absolute timeout (`remember_absolute_timeout` with +-- remember cookies). Write failures are always returned. +-- +-- @function module.revoke_subject +-- @tparam string subject subject +-- @tparam number ttl mark time-to-live in seconds +-- @tparam[opt] table configuration session @{configuration} overrides +-- @treturn boolean `true` when the mark was written, otherwise `nil` +-- @treturn string error message +-- +-- @usage +-- local ok, err = require("resty.session").revoke_subject("john@doe.com", 86400) +function session.revoke_subject(subject, ttl, configuration) + assert(type(subject) == "string" and subject ~= "", "invalid subject") + assert(type(ttl) == "number" and ttl > 0, "invalid revocation ttl") + + local self = session.new(configuration) + return revoke(self, subject_revocation_key(self, subject), ttl) +end + + +--- +-- Revoke sessions by sid. +-- +-- Writes a mark for an identity provider session id (see `session:set_sid`); +-- sessions carrying it that were created at or before now are rejected on +-- open. `ttl` must cover the sessions' absolute timeout +-- (`remember_absolute_timeout` with remember cookies). Write failures are +-- always returned. +-- +-- @function module.revoke_sid +-- @tparam string sid identity provider session id +-- @tparam number ttl mark time-to-live in seconds +-- @tparam[opt] table configuration session @{configuration} overrides +-- @treturn boolean `true` when the mark was written, otherwise `nil` +-- @treturn string error message +-- +-- @usage +-- local ok, err = require("resty.session").revoke_sid(logout_token.sid, 86400) +function session.revoke_sid(sid, ttl, configuration) + assert(type(sid) == "string" and sid ~= "", "invalid sid") + assert(type(ttl) == "number" and ttl > 0, "invalid revocation ttl") + + return revoke(session.new(configuration), sid_revocation_key(sid), ttl) +end + + function session.__set_ngx_log(ngx_log) log = ngx_log end diff --git a/spec/06-revocation-1_spec.lua b/spec/06-revocation-1_spec.lua index 4fdcbafb..ff8aba1e 100644 --- a/spec/06-revocation-1_spec.lua +++ b/spec/06-revocation-1_spec.lua @@ -149,8 +149,8 @@ for _, st in ipairs({ return extract_cookie(cookie_name, cookies["Set-Cookie"]) end - local function open_session(session_cookie) - local s = session.new() + local function open_session(session_cookie, configuration) + local s = session.new(configuration) session.__set_ngx_var({ ["cookie_" .. cookie_name] = session_cookie, }) @@ -163,6 +163,18 @@ for _, st in ipairs({ return s end + local function subject_mark_key(subject) + local s = session.new() + return s.hash_storage_key("subject:" .. s.hash_subject(subject)) + end + + local function sid_mark_key(sid) + return session.new().hash_storage_key("sid:" .. sid) + end + + local subjects = { "test", "legacy", "other", "aud-a" } + local sids = { "test" } + before_each(function() local conf = { cookie_name = cookie_name, @@ -173,6 +185,15 @@ for _, st in ipairs({ session.init(conf) end) + after_each(function() + for _, subject in ipairs(subjects) do + store:delete(cookie_name, subject_mark_key(subject), time()) + end + for _, sid in ipairs(sids) do + store:delete(cookie_name, sid_mark_key(sid), time()) + end + end) + it("open succeeds for a valid session with revocation enabled", function() local cookies = {} local s = session.new() @@ -186,6 +207,188 @@ for _, st in ipairs({ s2:close() end) + it("revoke_subject: session carrying a revoked key cannot be reopened", function() + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke_subject("test", long_ttl)) + + local s2, err = open_session(session_cookie) + assert.is_nil(s2) + assert.equals("session revoked", err) + end) + + it("revoke_subject: session created after the revocation opens", function() + assert.is_true(session.revoke_subject("test", long_ttl)) + sleep(1) + + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + s2:close() + end) + + it("revoke_subject: re-login after a revoked open gets a fresh session", function() + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke_subject("test", long_ttl)) + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + local s2, err, exists = session.open() + assert.equals("session revoked", err) + assert.is_false(exists) + + sleep(1) + s2:set_subject("test") + session_cookie = save_session(s2, cookies) + s2:close() + + local s3 + s3, err = open_session(session_cookie) + assert.is_not_nil(s3) + assert.is_nil(err) + s3:close() + end) + + it("revoke_subject: remember cookie carrying a revoked audience is not restored", function() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = st, + remember = true, + } + conf[st] = storage_configs[st] + session.init(conf) + + local cookies = {} + local a = session.new({ audience = "a" }) + a:set_remember(true) + a:set_subject("aud-a") + local session_cookie = save_session(a, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + a:close() + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + ["cookie_remember"] = remember_cookie, + }) + local b, err = session.open({ audience = "b" }) + assert.equals("missing session audience", err) + b:set_remember(true) + cookies = {} + save_session(b, cookies) + remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + assert.is_not_equal("", remember_cookie) + b:close() + + assert.is_true(session.revoke_subject("aud-a", long_ttl)) + + session.__set_ngx_header(cookies) + session.__set_ngx_var({ + ["cookie_remember"] = remember_cookie, + }) + local b2 = session.new({ audience = "b" }) + local ok = b2:open() + assert.is_nil(ok) + end) + + it("revoke_subject: remember cookie with a revoked audience is not restored for a new audience", function() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = st, + remember = true, + } + conf[st] = storage_configs[st] + session.init(conf) + + local cookies = {} + local a = session.new({ audience = "a" }) + a:set_remember(true) + a:set_subject("aud-a") + save_session(a, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + a:close() + + assert.is_true(session.revoke_subject("aud-a", long_ttl)) + + session.__set_ngx_var({ + ["cookie_remember"] = remember_cookie, + }) + local b, err = session.open({ audience = "b" }) + assert.is_not_nil(err) + cookies = {} + local session_cookie = save_session(b, cookies) + b:close() + + local a2 + a2, err = open_session(session_cookie, { audience = "a" }) + assert.is_nil(a2) + assert.equals("missing session audience", err) + end) + + it("revoke_subject: re-login after revocation issues a fresh remember cookie", function() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = st, + remember = true, + } + conf[st] = storage_configs[st] + session.init(conf) + + local cookies = {} + local s = session.new() + s:set_remember(true) + s:set_subject("test") + local session_cookie = save_session(s, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + s:close() + + assert.is_true(session.revoke_subject("test", long_ttl)) + sleep(1) + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + ["cookie_remember"] = remember_cookie, + }) + local s2, err = session.open() + assert.equals("session revoked", err) + + s2:set_remember(true) + s2:set_subject("test") + cookies = {} + save_session(s2, cookies) + remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + assert.is_not_equal("", remember_cookie) + s2:close() + + session.__set_ngx_header(cookies) + session.__set_ngx_var({ + ["cookie_remember"] = remember_cookie, + }) + local s3 = session.new() + local ok, err = s3:open() + assert.is_true(ok) + assert.is_nil(err) + s3:close() + end) + it("destroy: rejected cookie cannot be reopened", function() local cookies = {} local s = session.new() @@ -235,6 +438,186 @@ for _, st in ipairs({ assert.equals(value, s3:get(test_key)) s3:close() end) + + it("sid: round-trip through the cookie without a subject", function() + local cookies = {} + local s = session.new() + s:set_sid("test") + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + assert.equals("test", s2:get_sid()) + assert.is_nil(s2:get_subject()) + s2:close() + end) + + it("revoke_sid: session carrying a revoked sid cannot be reopened", function() + local cookies = {} + local s = session.new() + s:set_subject("other") + s:set_sid("test") + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke_sid("test", long_ttl)) + + local s2, err = open_session(session_cookie) + assert.is_nil(s2) + assert.equals("session revoked", err) + end) + + it("revoke_sid: does not revoke a session whose subject matches the sid", function() + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke_sid("test", long_ttl)) + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + s2:close() + end) + + it("subject mark: mark at or after creation revokes", function() + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + local ok = store:set(cookie_name, subject_mark_key("test"), tostring(time()), long_ttl, time()) + assert.is_not_nil(ok) + + local s2, err = open_session(session_cookie) + assert.is_nil(s2) + assert.equals("session revoked", err) + end) + + it("subject mark: mark before creation does not revoke", function() + local ok = store:set(cookie_name, subject_mark_key("test"), tostring(time() - 1), long_ttl, time()) + assert.is_not_nil(ok) + + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + assert.equals(value, s2:get(test_key)) + s2:close() + end) + + it("subject mark: legacy mark revokes regardless of time", function() + local ok = store:set(cookie_name, subject_mark_key("legacy"), "1", long_ttl, time()) + assert.is_not_nil(ok) + + local cookies = {} + local s = session.new() + s:set_subject("legacy") + local session_cookie = save_session(s, cookies) + s:close() + + local s2, err = open_session(session_cookie) + assert.is_nil(s2) + assert.equals("session revoked", err) + end) + + it("subject mark: mark for another key does not revoke", function() + local cookies = {} + local s = session.new() + s:set_subject("test") + local session_cookie = save_session(s, cookies) + s:close() + + local ok = store:set(cookie_name, subject_mark_key("other"), tostring(time()), long_ttl, time()) + assert.is_not_nil(ok) + + local s2, err = open_session(session_cookie) + assert.is_not_nil(s2) + assert.is_nil(err) + assert.equals(value, s2:get(test_key)) + s2:close() + end) + + it("subject mark: are scoped to the audience", function() + local cookies = {} + local s = session.new({ audience = "a" }) + s:set_subject("aud-a") + local session_cookie = save_session(s, cookies) + s:close() + + session.__set_ngx_var({ + ["cookie_" .. cookie_name] = session_cookie, + }) + local s2, err, exists = session.open({ audience = "b" }) + assert.is_false(exists) + assert.equals("missing session audience", err) + + local both_audiences_cookie = save_session(s2, cookies) + s2:close() + + local ok = store:set(cookie_name, subject_mark_key("aud-a"), tostring(time()), long_ttl, time()) + assert.is_not_nil(ok) + + local sb, err_b = open_session(both_audiences_cookie, { audience = "b" }) + assert.is_not_nil(sb) + assert.is_nil(err_b) + sb:close() + + local sa, err_a = open_session(both_audiences_cookie, { audience = "a" }) + assert.is_nil(sa) + assert.equals("session revoked", err_a) + end) + + it("subject mark: revoke a remembered session", function() + local conf = { + cookie_name = cookie_name, + storage = "cookie", + revocation = st, + remember = true, + } + conf[st] = storage_configs[st] + session.init(conf) + + local cookies = {} + local s = session.new() + s:set_remember(true) + s:set_subject("test") + save_session(s, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + assert.is_not_equal("", remember_cookie) + s:close() + + session.__set_ngx_header(cookies) + session.__set_ngx_var({ + ["cookie_remember"] = remember_cookie, + }) + local s2 = session.new() + local ok, err = s2:open() + assert.is_true(ok) + assert.is_nil(err) + s2:close() + + local mark_ok = store:set(cookie_name, subject_mark_key("test"), tostring(time()), long_ttl, time()) + assert.is_not_nil(mark_ok) + + session.__set_ngx_header(cookies) + session.__set_ngx_var({ + ["cookie_remember"] = remember_cookie, + }) + local s3 = session.new() + local ok3 = s3:open() + assert.is_nil(ok3) + end) end) end) end diff --git a/spec/07-revocation-2_spec.lua b/spec/07-revocation-2_spec.lua index 87e5bd8d..5c1e2d14 100644 --- a/spec/07-revocation-2_spec.lua +++ b/spec/07-revocation-2_spec.lua @@ -370,6 +370,26 @@ describe("Revocation tests 2 session: revocation_fail_mode", function() assert.matches("unable to mark session revoked", err) assert.equals("open", s.state) end) + + it("revoke_subject: reports a failed write even in open fail mode", function() + session.init({ + cookie_name = cookie_name, + storage = "cookie", + revocation = { + set = function() + return nil, "connection refused" + end, + get = function() + return nil + end, + }, + revocation_fail_mode = "open", + }) + + local ok, err = session.revoke_subject("test", 60) + assert.is_nil(ok) + assert.matches("unable to revoke session key", err) + end) end) @@ -419,4 +439,12 @@ describe("Revocation tests 2 session: Fields validation", function() assert.is_false(ok) assert.matches("invalid revocation fail mode", err) end) + + it("revoke_subject fails when revocation is not enabled", function() + session.init({ storage = "cookie" }) + + local ok, err = session.revoke_subject("test", 60) + assert.is_nil(ok) + assert.equals("session revocation is not enabled", err) + end) end)