Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ http {
* [Configuration](#configuration)
* [Session Configuration](#session-configuration)
* [Cookie Storage Configuration](#cookie-storage-configuration)
* [Session Revocation Configuration](#session-revocation-configuration)
* [DSHM Storage Configuration](#dshm-storage-configuration)
* [File Storage Configuration](#file-storage-configuration)
* [Memcached Storage Configuration](#memcached-storage-configuration)
Expand Down Expand Up @@ -327,6 +328,8 @@ Here are the possible session configuration options:
| `request_headers` | `nil` | Set of headers to send to upstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` request headers when `set_headers` is called. |
| `response_headers` | `nil` | Set of headers to send to downstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` response headers when `set_headers` is called. |
| `storage` | `nil` | Storage is responsible of storing session data, use `nil` or `"cookie"` (data is stored in cookie), `"dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, `"redis"`, or `"shm"`, or give a name of custom module (`"custom-storage"`), or a `table` that implements session storage interface. |
| `revocation` | `nil` | Enable Redis-backed session revocation for cookie (stateless) sessions, use `nil`, `true`, `false`, a Redis configuration `table`, or a `table` that implements the revocation store interface (see below). |
| `revocation_fail_mode` | `"open"` | Behavior when the revocation store is unreachable, use `"open"` (treat as not revoked) or `"closed"` (reject the session). |
| `dshm` | `nil` | Configuration for dshm storage, e.g. `{ prefix = "sessions" }` (see below) |
| `file` | `nil` | Configuration for file storage, e.g. `{ path = "/tmp", suffix = "session" }` (see below) |
| `memcached` | `nil` | Configuration for memcached storage, e.g. `{ prefix = "sessions" }` (see below) |
Expand All @@ -343,6 +346,56 @@ When storing data to cookie, there is no additional configuration required,
just set the `storage` to `nil` or `"cookie"`.


## Session Revocation Configuration

Cookie (stateless) sessions are self-contained: once issued, a cookie remains
valid until it expires according to the configured timeouts. Revocation adds
an optional Redis-backed denylist so that destroyed sessions are rejected
immediately, without waiting for the cookie to expire.

Revocation is only available when session data is stored in the cookie
(`storage` is `nil` or `"cookie"`). It must be enabled explicitly with
`revocation = true` (using the `redis` configuration) or
`revocation = { ... }` (inline Redis or custom store settings). Setting
`revocation = false` disables it.

On every `session:open`, the library checks whether the session identifier is
revoked. On `session:destroy`, the identifier is written to Redis 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 in
Redis.

Use `revocation_fail_mode` to control behavior when Redis is unreachable:

- `"open"` (default): log a warning and treat the session as not revoked.
Destroy still clears the cookie even if the revocation write fails.
- `"closed"`: reject the session open or destroy operation.

Revocation applies to `session:destroy` (and `session:logout` when it destroys
the last audience). It does not revoke the previous session identifier on
`session:save` (session rotation) or partial `session:logout` (multiple
audiences). After rotation or partial logout, the previous cookie remains
usable until its `stale_ttl` or timeout elapses.

Example:

```lua
require("resty.session").init({
storage = "cookie",
revocation = true,
redis = {
host = "127.0.0.1",
password = "secret",
prefix = "sessions",
},
})
```

The `redis.mode` setting selects whether a Redis connection is used for
session data (`"storage"`) or for revocation (`"revocation"`). When unset,
it defaults to `"revocation"` for cookie storage and `"storage"` otherwise.


## DSHM Storage Configuration

With DHSM storage you can use the following settings (set the `storage` to `"dshm"`):
Expand Down Expand Up @@ -529,6 +582,7 @@ connections. Common configuration settings among them all:

| Option | Default | Description |
|---------------------|:-------:|----------------------------------------------------------------------------------------------|
| `mode` | `nil` | Role of this Redis connection: `"storage"` for session data or `"revocation"` for the session denylist. Defaults to `"revocation"` when `storage` is `nil` or `"cookie"`, otherwise `"storage"`. |
| `prefix` | `nil` | Prefix for the keys stored in Redis. |
| `suffix` | `nil` | Suffix for the keys stored in Redis. |
| `username` | `nil` | The database username to authenticate. |
Expand Down
130 changes: 130 additions & 0 deletions lib/resty/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ local encode_base64url = utils.encode_base64url
local decode_base64url = utils.decode_base64url
local table_is_empty = utils.is_empty_table
local load_storage = utils.load_storage
local load_revocation = utils.load_revocation
local encode_json = utils.encode_json
local decode_json = utils.decode_json
local base64_size = utils.base64_size
Expand Down Expand Up @@ -148,6 +149,8 @@ local DEFAULT_FLAGS
local DEFAULT_REQUEST_HEADERS
local DEFAULT_RESPONSE_HEADERS
local DEFAULT_STORAGE
local DEFAULT_REVOCATION
local DEFAULT_REVOCATION_FAIL_MODE


local DUMMY_META = {}
Expand Down Expand Up @@ -372,6 +375,83 @@ local function get_store_ttl(self, remember, current_time, creation_time, rollin
end


local REVOCATION_MARK = "1"


local function handle_revocation_error(self, err, msg)
if self.revocation_fail_mode == "open" then
log(WARN, "[session] ", msg, ": ", err)
return true
end

return nil, errmsg(err, msg)
end


local function is_session_revoked(self, sid, cookie_name)
if self.storage or not sid then
return false, nil
end

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
end

local current_time = time()
local data, err = revocation:get(cookie_name, key, current_time)
if err then
local ok, rerr = handle_revocation_error(self, err, "unable to check session revocation")
if not ok then
return nil, rerr
end
return false, nil
end

if data == REVOCATION_MARK then
return true, nil
end

return false, nil
end


local function mark_session_revoked(self, remember, meta)
if self.storage then
return true
end

local revocation = self.revocation
if not revocation then
return true
end

local sid = meta and meta.sid
if not sid then
return true
end

local cookie_name = remember and self.remember_cookie_name or self.cookie_name
local key, herr = self.hash_storage_key(sid)
if not key then
return nil, herr
end

local current_time = time()
local ttl = get_store_ttl(self, remember, current_time, meta.creation_time, meta.rolling_offset)
local ok, err = revocation:set(cookie_name, key, REVOCATION_MARK, ttl, current_time)
if not ok then
return handle_revocation_error(self, err, "unable to mark session revoked")
end

return true
end


local function get_store_metadata(self)
if not self.store_metadata then
Expand Down Expand Up @@ -716,6 +796,14 @@ local function open(self, remember, meta_only)
end
end

local revoked, err = is_session_revoked(self, sid, cookie_name)
if err then
return nil, err
end
if revoked then
return nil, "session revoked"
end

local data_index = self.data_index
local audience = self.data[data_index][2]
local initial_chunk, ciphertext, ciphertext_encoded, info_data do
Expand Down Expand Up @@ -1253,6 +1341,11 @@ local function destroy(self, remember)
local cookie_name_size = #cookie_name
local storage = self.storage

local ok, err = mark_session_revoked(self, remember, meta)
if not ok then
return nil, err
end

local cookie_chunks = 1
local data_size = meta.data_size
if not storage and data_size then
Expand Down Expand Up @@ -2341,6 +2434,8 @@ local session = {
-- @field request_headers Set of headers to send to upstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` request headers when `set_headers` is called.
-- @field response_headers Set of headers to send to downstream, use `id`, `audience`, `subject`, `timeout`, `idling-timeout`, `rolling-timeout`, `absolute-timeout`. E.g. `{ "id", "timeout" }` will set `Session-Id` and `Session-Timeout` response headers when `set_headers` is called.
-- @field storage Storage is responsible of storing session data, use `nil` or `"cookie"` (data is stored in cookie), `"dshm"`, `"file"`, `"memcached"`, `"mysql"`, `"postgres"`, `"redis"`, or `"shm"`, or give a name of custom module (`"custom-storage"`), or a `table` that implements session storage interface (defaults to `nil`)
-- @field revocation Session revocation backend for cookie (stateless) sessions, use `nil` (auto-load from `redis` when configured), `false` to disable, `"redis"`, `true` (alias for `"redis"`), or a pre-built store `table` with `set`/`get` methods (defaults to `nil`)
-- @field revocation_fail_mode Behavior when the revocation store is unreachable, use `"open"` (treat as not revoked) or `"closed"` (reject the session) (defaults to `"open"`)
-- @field dshm Configuration for dshm storage, e.g. `{ prefix = "sessions" }`
-- @field file Configuration for file storage, e.g. `{ path = "/tmp", suffix = "session" }`
-- @field memcached Configuration for memcached storage, e.g. `{ prefix = "sessions" }`
Expand Down Expand Up @@ -2401,6 +2496,10 @@ local function opt(configuration, name, default)
end
end
end

elseif name == "revocation" then
value = load_revocation(nil, configuration)

end

else
Expand Down Expand Up @@ -2451,6 +2550,31 @@ local function opt(configuration, name, default)
assert(t == "table", "invalid session storage")
end
end

elseif name == "revocation" then
if value == false then
value = nil

else
local t = type(value)
if t == "string" then
value = assert(load_revocation(value, configuration), "unable to load session revocation")

elseif value == true then
value = assert(load_revocation("redis", configuration), "unable to load session revocation")

elseif t == "table" then
if type(value.set) ~= "function" or type(value.get) ~= "function" then
error("invalid session revocation")
end

else
error("invalid session revocation")
end
end

elseif name == "revocation_fail_mode" then
assert(value == "open" or value == "closed", "invalid revocation fail mode")
end
end

Expand Down Expand Up @@ -2497,6 +2621,8 @@ function session.init(configuration)
DEFAULT_REQUEST_HEADERS = opt(configuration, "request_headers")
DEFAULT_RESPONSE_HEADERS = opt(configuration, "response_headers")
DEFAULT_STORAGE = opt(configuration, "storage")
DEFAULT_REVOCATION = opt(configuration, "revocation")
DEFAULT_REVOCATION_FAIL_MODE = opt(configuration, "revocation_fail_mode", "open")
end

---
Expand Down Expand Up @@ -2553,6 +2679,8 @@ function session.new(configuration)
local request_headers = opt(configuration, "request_headers", DEFAULT_REQUEST_HEADERS)
local response_headers = opt(configuration, "response_headers", DEFAULT_RESPONSE_HEADERS)
local storage = opt(configuration, "storage", DEFAULT_STORAGE)
local revocation = opt(configuration, "revocation", DEFAULT_REVOCATION)
local revocation_fail_mode = opt(configuration, "revocation_fail_mode", DEFAULT_REVOCATION_FAIL_MODE)

if cookie_prefix == "__Host-" then
cookie_name = cookie_prefix .. cookie_name
Expand Down Expand Up @@ -2625,6 +2753,8 @@ function session.new(configuration)
remember = remember,
flags = flags,
storage = storage,
revocation = revocation,
revocation_fail_mode = revocation_fail_mode,
ikm = ikm,
ikm_fallbacks = ikm_fallbacks,
request_headers = request_headers,
Expand Down
73 changes: 73 additions & 0 deletions lib/resty/session/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,8 @@ local load_storage do
elseif storage == "redis" then
local cfg = configuration and configuration.redis
if cfg then
assert(cfg.mode ~= "revocation", "invalid redis mode for session storage")

if cfg.nodes then
if not REDIS_CLUSTER then
REDIS_CLUSTER = require("resty.session.redis.cluster")
Expand Down Expand Up @@ -959,6 +961,76 @@ local load_storage do
end



local load_revocation do
local REDIS
local CUSTOM = {}

---
-- Loads session revocation store and creates a new instance using session configuration.
--
-- @function utils.load_revocation
-- @tparam nil|boolean|string revocation revocation store name, `nil` to auto-load from
-- `redis` when configured for revocation, `true` for `"redis"`, or `false` to disable
-- @tparam[opt] table configuration session configuration
-- @treturn table|nil instance of session revocation store
-- @treturn string|nil error message
--
-- @usage
-- local redis = require("resty.session.utils").load_revocation("redis", {
-- redis = {
-- host = "127.0.0.1",
-- }
-- })
load_revocation = function(revocation, configuration)
if revocation == false or revocation == "cookie" then
return nil
end

if revocation == true then
revocation = "redis"
end

local session_storage = configuration and configuration.storage
if session_storage and session_storage ~= "cookie" then
return nil
end

if not revocation then
local redis_cfg = configuration and configuration.redis
if not redis_cfg or not redis_cfg.host or redis_cfg.mode == "storage" then
return nil
end

revocation = "redis"
end

if type(revocation) ~= "string" then
error("invalid session revocation")
end

if revocation == "redis" then
local cfg = configuration and configuration.redis
if not cfg or not cfg.host or cfg.mode == "storage" then
return nil
end

if not REDIS then
REDIS = require("resty.session.redis")
end
return REDIS.new(cfg)

else
if not CUSTOM[revocation] then
CUSTOM[revocation] = require(revocation)
end
return CUSTOM[revocation].new(configuration and configuration[revocation])
end
end
end



---
-- Helper to format error messages.
--
Expand Down Expand Up @@ -1195,6 +1267,7 @@ return {
decrypt_aes_256_gcm = decrypt_aes_256_gcm,
hmac_sha256 = hmac_sha256,
load_storage = load_storage,
load_revocation = load_revocation,
errmsg = errmsg,
get_name = get_name,
set_flag = set_flag,
Expand Down
Loading