diff --git a/README.md b/README.md index bac2f9fb..a069dad9 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ http { * [session.start](#sessionstart) * [session.logout](#sessionlogout) * [session.destroy](#sessiondestroy) + * [session.revoke](#sessionrevoke) * [Instance Methods](#instance-methods) * [session:open](#sessionopen-1) * [session:save](#sessionsave) @@ -216,6 +217,8 @@ http { * [session:get_audience](#sessionget_audience) * [session:set_subject](#sessionset_subject) * [session:get_subject](#sessionget_subject) + * [session:set_revocation_keys](#sessionset_revocation_keys) + * [session:get_revocation_keys](#sessionget_revocation_keys) * [session:get_property](#sessionget_property) * [session:set_remember](#sessionset_remember) * [session:get_remember](#sessionget_remember) @@ -367,6 +370,24 @@ 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 may also carry application supplied revocation keys (for example an +identity provider's `sid` and `sub`), set with `session:set_revocation_keys`. +`session.revoke(key, ttl, configuration)` marks a key without an open session; +sessions carrying it 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). Keys go +through `hash_storage_key`, enable it when they may contain personal data. +`session.revoke` always returns write errors; `revocation_fail_mode` applies +to `session:open` and `session:destroy` only. + +```lua +-- On login +session:set_revocation_keys({ "sid:" .. sid, "sub:" .. sub }) + +-- In a back-channel logout handler, without an open session +require("resty.session").revoke("sid:" .. 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 +905,23 @@ local ok, err, exists, destroyed = require "resty.session".destroy({ See [configuration](#configuration) for possible configuration settings. +### session.revoke + +**syntax:** *ok, err = session.revoke(key, ttl, configuration)* + +It marks a revocation key (see `session:set_revocation_keys`) without an open +session; sessions carrying the key 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:" .. sid, 86400) +``` + +See [configuration](#configuration) for possible configuration settings. + + ## Instance Methods ### session:open @@ -1117,6 +1155,33 @@ end ``` +### session:set_revocation_keys + +**syntax:** *session:set_revocation_keys(keys)* + +Set application supplied revocation keys, e.g. an identity provider's `sid` +and `sub`, so that `session.revoke` can revoke the session by them. + +```lua +local session = require "resty.session".new() +session:set_revocation_keys({ "sid:" .. sid, "sub:" .. sub }) +``` + + +### session:get_revocation_keys + +**syntax:** *keys = session:get_revocation_keys()* + +Get session revocation keys. + +```lua +local session, err, exists = require "resty.session".open() +if exists then + local keys = session:get_revocation_keys() +end +``` + + ### session:get_property **syntax:** *value = session:get_property(name)* diff --git a/lib/resty/session.lua b/lib/resty/session.lua index 2ad91093..b6bac2be 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,11 +407,12 @@ 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 - return false, nil + local revoked_at = tonumber(mark) + return revoked_at ~= nil and revoked_at >= creation_time, nil end @@ -795,7 +791,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 +941,33 @@ 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 as userdata + if type(data[i][3]) == "userdata" then + data[i][3] = nil + end + + if not audience_index and data[i][2] == audience then audience_index = i - break + end + end + + for i = 1, count do + local keys = data[i][4] + if keys and (remember or i == audience_index) then + for j = 1, #keys do + local revoked, err = is_revoked(self, keys[j], self.cookie_name, 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 +1962,43 @@ function metatable:get_subject() end +--- +-- Set session revocation keys. +-- +-- Application supplied identifiers (e.g. an identity provider's session +-- or subject id) carried in the payload; `session.revoke` revokes sessions +-- by them. +-- +-- @function instance:set_revocation_keys +-- @tparam table|nil keys array of revocation keys (`nil` clears them) +-- +-- @usage +-- local session = require("resty.session").new() +-- session:set_revocation_keys({ "sid:" .. sid, "sub:" .. sub }) +function metatable:set_revocation_keys(keys) + assert(self.state ~= STATE_CLOSED, "unable to set revocation keys on closed session") + assert(keys == nil or type(keys) == "table", "invalid revocation keys") + self.data[self.data_index][4] = keys +end + + +--- +-- Get session revocation keys. +-- +-- @function instance:get_revocation_keys +-- @treturn table|nil array of revocation keys +-- +-- @usage +-- local session, err, exists = require("resty.session").open() +-- if exists then +-- local keys = session:get_revocation_keys() +-- end +function metatable:get_revocation_keys() + assert(self.state ~= STATE_CLOSED, "unable to get revocation keys on closed session") + return self.data[self.data_index][4] +end + + --- -- Get session property. -- @@ -2925,6 +2982,49 @@ function session.destroy(configuration) end +--- +-- Revoke sessions by a revocation key. +-- +-- Writes a mark for an application supplied key (see +-- `session:set_revocation_keys`); 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 +-- @tparam string key revocation key +-- @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("sub:" .. sub, 86400) +function session.revoke(key, ttl, configuration) + assert(type(key) == "string" and key ~= "", "invalid revocation key") + assert(type(ttl) == "number" and ttl > 0, "invalid revocation ttl") + + local self = session.new(configuration) + 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 + + 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..1126b190 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,12 @@ for _, st in ipairs({ return s end + local function mark_key(key) + return session.new().hash_storage_key(key) + end + + local revocation_keys = { "sub:test", "sub:legacy", "sub:other", "sub:aud-a" } + before_each(function() local conf = { cookie_name = cookie_name, @@ -173,6 +179,12 @@ for _, st in ipairs({ session.init(conf) end) + after_each(function() + for _, k in ipairs(revocation_keys) do + store:delete(cookie_name, mark_key(k), time()) + end + end) + it("open succeeds for a valid session with revocation enabled", function() local cookies = {} local s = session.new() @@ -186,6 +198,188 @@ for _, st in ipairs({ s2:close() end) + it("revoke: session carrying a revoked key cannot be reopened", function() + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub:test" }) + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke("sub:test", long_ttl)) + + local s2, err = open_session(session_cookie) + assert.is_nil(s2) + assert.equals("session revoked", err) + end) + + it("revoke: session created after the revocation opens", function() + assert.is_true(session.revoke("sub:test", long_ttl)) + sleep(1) + + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub: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: re-login after a revoked open gets a fresh session", function() + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub:test" }) + local session_cookie = save_session(s, cookies) + s:close() + + assert.is_true(session.revoke("sub: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_revocation_keys({ "sub: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: 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_revocation_keys({ "sub: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("sub: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: 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_revocation_keys({ "sub:aud-a" }) + save_session(a, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + a:close() + + assert.is_true(session.revoke("sub: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: 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_revocation_keys({ "sub:test" }) + local session_cookie = save_session(s, cookies) + local remember_cookie = extract_cookie("remember", cookies["Set-Cookie"]) + s:close() + + assert.is_true(session.revoke("sub: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_revocation_keys({ "sub: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 +429,156 @@ for _, st in ipairs({ assert.equals(value, s3:get(test_key)) s3:close() end) + + it("revocation keys: round-trip through the cookie without a subject", function() + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub:test", "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.same({ "sub:test", "sid:test" }, s2:get_revocation_keys()) + assert.is_nil(s2:get_subject()) + s2:close() + end) + + it("revocation keys: mark at or after creation revokes", function() + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub:test" }) + local session_cookie = save_session(s, cookies) + s:close() + + local ok = store:set(cookie_name, mark_key("sub: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("revocation keys: mark before creation does not revoke", function() + local ok = store:set(cookie_name, mark_key("sub:test"), tostring(time() - 1), long_ttl, time()) + assert.is_not_nil(ok) + + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub: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("revocation keys: legacy mark revokes regardless of time", function() + local ok = store:set(cookie_name, mark_key("sub:legacy"), "1", long_ttl, time()) + assert.is_not_nil(ok) + + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub: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("revocation keys: mark for another key does not revoke", function() + local cookies = {} + local s = session.new() + s:set_revocation_keys({ "sub:test" }) + local session_cookie = save_session(s, cookies) + s:close() + + local ok = store:set(cookie_name, mark_key("sub: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("revocation keys: are scoped to the audience", function() + local cookies = {} + local s = session.new({ audience = "a" }) + s:set_revocation_keys({ "sub: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, mark_key("sub: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("revocation keys: 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_revocation_keys({ "sub: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, mark_key("sub: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..27e5251e 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: 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("sub: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 fails when revocation is not enabled", function() + session.init({ storage = "cookie" }) + + local ok, err = session.revoke("sub:test", 60) + assert.is_nil(ok) + assert.equals("session revocation is not enabled", err) + end) end)