diff --git a/db/schema.sql b/db/schema.sql index 77b21a314..06489afab 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1271,12 +1271,10 @@ ALTER SEQUENCE api_umbrella.published_config_id_seq OWNED BY api_umbrella.publis -- CREATE TABLE api_umbrella.sessions ( - id_hash character varying(64) NOT NULL, - data_encrypted bytea NOT NULL, - data_encrypted_iv character varying(12) NOT NULL, - expires_at timestamp with time zone NOT NULL, - created_at timestamp with time zone DEFAULT transaction_timestamp() NOT NULL, - updated_at timestamp with time zone DEFAULT transaction_timestamp() NOT NULL + sid text NOT NULL, + name text, + data text, + exp timestamp with time zone ); @@ -1842,7 +1840,7 @@ ALTER TABLE ONLY api_umbrella.rate_limits -- ALTER TABLE ONLY api_umbrella.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id_hash); + ADD CONSTRAINT sessions_pkey PRIMARY KEY (sid); -- @@ -2034,7 +2032,7 @@ CREATE UNIQUE INDEX distributed_rate_limit_counters_version_idx ON api_umbrella. -- Name: sessions_expires_at_idx; Type: INDEX; Schema: api_umbrella; Owner: - -- -CREATE INDEX sessions_expires_at_idx ON api_umbrella.sessions USING btree (expires_at); +CREATE INDEX sessions_exp_idx ON api_umbrella.sessions USING btree (exp); -- @@ -2618,13 +2616,6 @@ CREATE TRIGGER published_config_stamp_record BEFORE INSERT OR DELETE OR UPDATE O CREATE TRIGGER rate_limits_stamp_record BEFORE INSERT OR DELETE OR UPDATE ON api_umbrella.rate_limits FOR EACH ROW EXECUTE FUNCTION api_umbrella.stamp_record('[{"table_name":"api_backend_settings","primary_key":"id","foreign_key":"api_backend_settings_id"},{"table_name":"api_user_settings","primary_key":"id","foreign_key":"api_user_settings_id"}]'); --- --- Name: sessions sessions_stamp_record; Type: TRIGGER; Schema: api_umbrella; Owner: - --- - -CREATE TRIGGER sessions_stamp_record BEFORE UPDATE ON api_umbrella.sessions FOR EACH ROW EXECUTE FUNCTION api_umbrella.update_timestamp(); - - -- -- Name: website_backends website_backends_stamp_record; Type: TRIGGER; Schema: api_umbrella; Owner: - -- diff --git a/docker/dev/docker-entrypoint b/docker/dev/docker-entrypoint index 4a901121d..b71d1b0de 100755 --- a/docker/dev/docker-entrypoint +++ b/docker/dev/docker-entrypoint @@ -15,6 +15,7 @@ mkdir -p /etc/api-umbrella echo " password: dev_password" } > /etc/api-umbrella/api-umbrella.yml +/app/configure mkdir -p /build/.task ln -snf /build/.task /app/.task diff --git a/src/api-umbrella-git-1.rockspec b/src/api-umbrella-git-1.rockspec index e15c1a214..3978b9d1a 100644 --- a/src/api-umbrella-git-1.rockspec +++ b/src/api-umbrella-git-1.rockspec @@ -16,8 +16,8 @@ dependencies = { "lua-resty-mail ~> 1.2.0", "lua-resty-mlcache ~> 2.7.0", "lua-resty-nettle ~> 2.1", - "lua-resty-openidc ~> 1.7.6", - "lua-resty-session ~> 3.10", + "lua-resty-openidc ~> 1.8.0", + "lua-resty-session ~> 4.0", "lua-resty-txid ~> 1.0.0", "lua-resty-uuid ~> 1.1", "lua-resty-validation ~> 2.7", diff --git a/src/api-umbrella/proxy/jobs/db_expirations.lua b/src/api-umbrella/proxy/jobs/db_expirations.lua index 20d747e7e..7b2f1c09f 100644 --- a/src/api-umbrella/proxy/jobs/db_expirations.lua +++ b/src/api-umbrella/proxy/jobs/db_expirations.lua @@ -10,7 +10,7 @@ local function do_run() "DELETE FROM analytics_cache WHERE expires_at IS NOT NULL AND expires_at < now()", "DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < now()", "DELETE FROM distributed_rate_limit_counters WHERE expires_at < now()", - "DELETE FROM sessions WHERE expires_at < now()", + "DELETE FROM sessions WHERE exp < now()", } for _, query in ipairs(queries) do local result, err = pg_utils.query(query, nil, { quiet = true }) diff --git a/src/api-umbrella/utils/pg_utils.lua b/src/api-umbrella/utils/pg_utils.lua index 45695a584..9cbbcb5f5 100644 --- a/src/api-umbrella/utils/pg_utils.lua +++ b/src/api-umbrella/utils/pg_utils.lua @@ -207,7 +207,11 @@ function _M.connect() return nil, err end - _M.setup_connection(pg, "api-umbrella") + -- Always force session variable setup on every connection, even for reused + -- keepalive sockets. Other libraries (e.g., lua-resty-session's postgres + -- storage) may share the same connection pool and return connections without + -- the expected search_path set. + _M.setup_connection(pg, "api-umbrella", true) return pg end diff --git a/src/api-umbrella/web-app/actions/admin/sessions.lua b/src/api-umbrella/web-app/actions/admin/sessions.lua index 7bf53ded0..b0cc61ab1 100644 --- a/src/api-umbrella/web-app/actions/admin/sessions.lua +++ b/src/api-umbrella/web-app/actions/admin/sessions.lua @@ -139,12 +139,12 @@ end function _M.destroy(self) self:init_session_db() - local _, _, open_err = self.session_db:start() - if open_err then + local ok, open_err = self.session_db:open() + if not ok and open_err and open_err ~= "missing session cookie" then ngx.log(ngx.ERR, "session open error: ", open_err) end - local sign_in_provider = self.session_db.data["sign_in_provider"] + local sign_in_provider = self.session_db:get("sign_in_provider") self.session_db:destroy() flash.session(self, "info", t("Signed out successfully.")) @@ -173,8 +173,11 @@ function _M.logout_callback(self) local state = ngx.var.arg_state if state then self:init_session_cookie() - self.session_cookie:start() - local session_state = self.session_cookie.data["openid_connect_state"] + local ok, open_err = self.session_cookie:open() + if not ok and open_err and open_err ~= "missing session cookie" then + ngx.log(ngx.ERR, "session open error: ", open_err) + end + local session_state = self.session_cookie:get("openid_connect_state") if state ~= session_state then ngx.log(ngx.WARN, "state from argument: " .. (state or "nil") .. " does not match state restored from session: " .. (session_state or "nil")) diff --git a/src/api-umbrella/web-app/app.lua b/src/api-umbrella/web-app/app.lua index 15ad63db5..ba0760081 100644 --- a/src/api-umbrella/web-app/app.lua +++ b/src/api-umbrella/web-app/app.lua @@ -10,17 +10,12 @@ local http_headers = require "api-umbrella.utils.http_headers" local is_empty = require "api-umbrella.utils.is_empty" local lapis = require "lapis" local lapis_config = require("lapis.config").get() +local pg_utils = require "api-umbrella.utils.pg_utils" local refresh_local_active_config_cache = require("api-umbrella.web-app.stores.active_config_store").refresh_local_cache local resty_session = require "resty.session" local t = require("api-umbrella.web-app.utils.gettext").gettext local table_keys = require("pl.tablex").keys -require "resty.session.ciphers.api_umbrella" -require "resty.session.hmac.api_umbrella" -require "resty.session.identifiers.api_umbrella" -require "resty.session.storage.api_umbrella_db" -require "resty.session.serializers.api_umbrella" - local supported_languages = table_keys(LOCALE_DATA) -- Custom error handler so we only show the default lapis debug details in @@ -77,24 +72,26 @@ end -- server-side control on expiring sessions, and it can't be spoofed even with -- knowledge of the encryption secret key. local session_db_options = { - storage = "api_umbrella_db", - cipher = "api_umbrella", - hmac = "api_umbrella", - serializer = "api_umbrella", - identifier = "api_umbrella", - name = "_api_umbrella_session", - secret = assert(config["secret_key"]), - random = { - length = 40, - }, - cookie = { - samesite = "Lax", - secure = true, - httponly = true, - idletime = 30 * 60, -- 30 minutes - lifetime = 12 * 60 * 60, -- 12 hours - renew = -1, -- Disable renew + storage = "postgres", + postgres = { + host = pg_utils.db_config.host, + port = pg_utils.db_config.port, + database = pg_utils.db_config.database, + username = pg_utils.db_config.user, + password = pg_utils.db_config.password, + ssl = pg_utils.db_config.ssl, + ssl_verify = pg_utils.db_config.ssl_verify, + ssl_required = pg_utils.db_config.ssl_required, + table = "api_umbrella.sessions", }, + secret = assert(config["secret_key"]), + cookie_name = "_api_umbrella_session", + cookie_same_site = "Lax", + cookie_secure = true, + cookie_http_only = true, + idling_timeout = 30 * 60, -- 30 minutes + rolling_timeout = 0, -- disabled, matches v3 renew=-1 + absolute_timeout = 12 * 60 * 60, -- 12 hours } local function init_session_db(self) if not self.session_db then @@ -112,22 +109,14 @@ end -- session records in the database for the CSRF token). local session_cookie_options = { storage = "cookie", - cipher = "api_umbrella", - hmac = "api_umbrella", - serializer = "api_umbrella", - identifier = "api_umbrella", - name = "_api_umbrella_session_client", secret = assert(config["secret_key"]), - random = { - length = 40, - }, - cookie = { - samesite = "Lax", - secure = true, - httponly = true, - lifetime = 48 * 60 * 60, -- 48 hours - renew = 1 * 60 * 60, -- 1 hour - }, + cookie_name = "_api_umbrella_session_client", + cookie_same_site = "Lax", + cookie_secure = true, + cookie_http_only = true, + idling_timeout = 0, -- disabled for cookie-only sessions + rolling_timeout = 1 * 60 * 60, -- 1 hour + absolute_timeout = 48 * 60 * 60, -- 48 hours } local function init_session_cookie(self) if not self.session_cookie then @@ -138,17 +127,19 @@ end local function current_admin_from_session(self) local current_admin self:init_session_db() - local _, _, open_err = self.session_db:start() - if open_err then - if open_err == "session cookie idle time has passed" or open_err == "session cookie has expired" then - flash.session(self, "info", t("Your session expired. Please sign in again to continue.")) - else - ngx.log(ngx.ERR, "session open error: ", open_err) + local ok, open_err = self.session_db:open() + if not ok then + if open_err and open_err ~= "missing session cookie" then + if open_err == "session idling timeout exceeded" or open_err == "session absolute timeout exceeded" then + flash.session(self, "info", t("Your session expired. Please sign in again to continue.")) + else + ngx.log(ngx.ERR, "session open error: ", open_err) + end end end - if self.session_db and self.session_db.data and self.session_db.data["admin_id"] then - local admin_id = self.session_db.data["admin_id"] + local admin_id = self.session_db:get("admin_id") + if admin_id then local admin = Admin:find({ id = admin_id }) if admin and not admin:is_access_locked() then current_admin = admin diff --git a/src/api-umbrella/web-app/hooks/init_preload_modules.lua b/src/api-umbrella/web-app/hooks/init_preload_modules.lua index 165d08e41..e8b604877 100644 --- a/src/api-umbrella/web-app/hooks/init_preload_modules.lua +++ b/src/api-umbrella/web-app/hooks/init_preload_modules.lua @@ -182,11 +182,6 @@ require "resty.http" require "resty.mlcache" require "resty.openidc" require "resty.session" -require "resty.session.ciphers.api_umbrella" -require "resty.session.hmac.api_umbrella" -require "resty.session.identifiers.api_umbrella" -require "resty.session.serializers.api_umbrella" -require "resty.session.storage.api_umbrella_db" require "resty.uuid" require "resty.validation" require "resty.validation.ngx" diff --git a/src/api-umbrella/web-app/utils/auth_external_oauth2.lua b/src/api-umbrella/web-app/utils/auth_external_oauth2.lua index ef8c928c2..c6ae0a8c4 100644 --- a/src/api-umbrella/web-app/utils/auth_external_oauth2.lua +++ b/src/api-umbrella/web-app/utils/auth_external_oauth2.lua @@ -89,8 +89,8 @@ end function _M.authorize(self, strategy_name, url, params) local state = random_token(64) self:init_session_cookie() - self.session_cookie:start() - self.session_cookie.data["oauth2_state"] = state + self.session_cookie:open() + self.session_cookie:set("oauth2_state", state) self.session_cookie:save() local callback_url = build_url(auth_external_path(strategy_name, "/callback")) @@ -119,17 +119,16 @@ function _M.userinfo(self, strategy_name, options) end self:init_session_cookie() - local _, _, open_err = self.session_cookie:start() - if open_err then + local ok, open_err = self.session_cookie:open() + if not ok and open_err and open_err ~= "missing session cookie" then ngx.log(ngx.ERR, "session open error: ", open_err) end - if not self.session_cookie or not self.session_cookie.data or not self.session_cookie.data["oauth2_state"] then + local stored_state = self.session_cookie:get("oauth2_state") + if not stored_state then ngx.log(ngx.ERR, "oauth2 state not available") return nil, t("Cross-site request forgery detected") end - - local stored_state = self.session_cookie.data["oauth2_state"] local state = self.params["state"] if state ~= stored_state then ngx.log(ngx.ERR, "oauth2 state does not match") diff --git a/src/api-umbrella/web-app/utils/auth_external_openid_connect.lua b/src/api-umbrella/web-app/utils/auth_external_openid_connect.lua index fdc2d741f..8bdfac15d 100644 --- a/src/api-umbrella/web-app/utils/auth_external_openid_connect.lua +++ b/src/api-umbrella/web-app/utils/auth_external_openid_connect.lua @@ -43,8 +43,8 @@ function _M.authenticate(self, strategy_name, callback) -- Call the provider-specific callback logic, which should handle -- authorizing the API Umbrella session and redirecting as appropriate. callback({ - id_token = session["data"]["id_token"], - user = session["data"]["user"], + id_token = session:get("id_token"), + user = session:get("user"), }) -- This shouldn't get hit, since callback should perform it's own @@ -83,13 +83,14 @@ function _M.authenticate(self, strategy_name, callback) if discovery and discovery["end_session_endpoint"] then -- Generate the state parameter to send. self:init_session_cookie() - self.session_cookie:start() - self.session_cookie.data["openid_connect_state"] = random_token(64) + self.session_cookie:open() + local oidc_state = random_token(64) + self.session_cookie:set("openid_connect_state", oidc_state) self.session_cookie:save() -- Add the "state" param to the logout URL. local extra_logout_args = { - state = self.session_cookie.data["openid_connect_state"] + state = oidc_state } -- Add the "client_id" param to the logout URL if id_token_hint won't be @@ -121,7 +122,7 @@ function _M.authenticate(self, strategy_name, callback) -- Create a separate session for lua-resty-openidc's storage so it doesn't -- conflict with any of our sessions. local session_options = deepcopy(self.session_db_options) - session_options["name"] = "_api_umbrella_openidc" + session_options["cookie_name"] = "_api_umbrella_openidc" -- In the test environment allow mocking the login process. if config["app_env"] == "test" and ngx.var.cookie_test_mock_userinfo then diff --git a/src/api-umbrella/web-app/utils/csrf.lua b/src/api-umbrella/web-app/utils/csrf.lua index 32212eaae..1a30b620e 100644 --- a/src/api-umbrella/web-app/utils/csrf.lua +++ b/src/api-umbrella/web-app/utils/csrf.lua @@ -17,18 +17,18 @@ local _M = {} function _M.generate_token(self) self:init_session_cookie() - self.session_cookie:start() - local csrf_token_key = self.session_cookie.data["csrf_token_key"] - local csrf_token_iv = self.session_cookie.data["csrf_token_iv"] + self.session_cookie:open() + local csrf_token_key = self.session_cookie:get("csrf_token_key") + local csrf_token_iv = self.session_cookie:get("csrf_token_iv") if not csrf_token_key or not csrf_token_iv then if not csrf_token_key then csrf_token_key = random_token(40) - self.session_cookie.data["csrf_token_key"] = csrf_token_key + self.session_cookie:set("csrf_token_key", csrf_token_key) end if not csrf_token_iv then csrf_token_iv = random_token(12) - self.session_cookie.data["csrf_token_iv"] = csrf_token_iv + self.session_cookie:set("csrf_token_iv", csrf_token_iv) end self.session_cookie:save() @@ -41,12 +41,12 @@ end local function validate_token(self) self:init_session_cookie() - local _, _, open_err = self.session_cookie:start() - if open_err then + local ok, open_err = self.session_cookie:open() + if not ok and open_err and open_err ~= "missing session cookie" then ngx.log(ngx.ERR, "session open error: ", open_err) end - local key = self.session_cookie.data["csrf_token_key"] + local key = self.session_cookie:get("csrf_token_key") if not key then return false, "Missing CSRF token key" end diff --git a/src/api-umbrella/web-app/utils/flash.lua b/src/api-umbrella/web-app/utils/flash.lua index 931d25c81..0dba15e7c 100644 --- a/src/api-umbrella/web-app/utils/flash.lua +++ b/src/api-umbrella/web-app/utils/flash.lua @@ -14,11 +14,13 @@ function _M.session(self, flash_type, message, options) data["message"] = message self:init_session_cookie() - self.session_cookie:start() - if not self.session_cookie.data["flash"] then - self.session_cookie.data["flash"] = {} + self.session_cookie:open() + local flash_data = self.session_cookie:get("flash") + if not flash_data then + flash_data = {} end - self.session_cookie.data["flash"][flash_type] = data + flash_data[flash_type] = data + self.session_cookie:set("flash", flash_data) self.session_cookie:save() end @@ -27,17 +29,18 @@ function _M.setup(self) self.restore_flashes = function() self:init_session_cookie() - local _, _, open_err = self.session_cookie:start() - if open_err then + local ok, open_err = self.session_cookie:open() + if not ok and open_err and open_err ~= "missing session cookie" then ngx.log(ngx.ERR, "session open error: ", open_err) end - if self.session_cookie.data and not is_empty(self.session_cookie.data["flash"]) then - for flash_type, data in pairs(self.session_cookie.data["flash"]) do + local flash_data = self.session_cookie:get("flash") + if not is_empty(flash_data) then + for flash_type, data in pairs(flash_data) do _M.now(self, flash_type, data["message"], data) end - self.session_cookie.data["flash"] = nil + self.session_cookie:set("flash", nil) self.session_cookie:save() end diff --git a/src/api-umbrella/web-app/utils/login_admin.lua b/src/api-umbrella/web-app/utils/login_admin.lua index baa865221..4a3403ccc 100644 --- a/src/api-umbrella/web-app/utils/login_admin.lua +++ b/src/api-umbrella/web-app/utils/login_admin.lua @@ -17,9 +17,9 @@ return function(self, admin, provider) db.query("COMMIT") self:init_session_db() - self.session_db:start() - self.session_db.data["admin_id"] = admin_id - self.session_db.data["sign_in_provider"] = provider + self.session_db:open() + self.session_db:set("admin_id", admin_id) + self.session_db:set("sign_in_provider", provider) self.session_db:save() return build_url("/admin/#/login") diff --git a/src/luarocks.lock b/src/luarocks.lock index eee72bd32..ef6b323e6 100644 --- a/src/luarocks.lock +++ b/src/luarocks.lock @@ -18,9 +18,9 @@ return { ["lua-resty-mail"] = "1.2.0-1", ["lua-resty-mlcache"] = "2.7.0-1", ["lua-resty-nettle"] = "2.1-1", - ["lua-resty-openidc"] = "1.7.6-3", + ["lua-resty-openidc"] = "1.8.0-1", ["lua-resty-openssl"] = "1.7.1-1", - ["lua-resty-session"] = "3.10-1", + ["lua-resty-session"] = "4.0.5-1", ["lua-resty-txid"] = "1.0.0-1", ["lua-resty-uuid"] = "1.1-1", ["lua-resty-validation"] = "2.7-1", diff --git a/src/migrations.lua b/src/migrations.lua index 2d755fc78..91b52fa4d 100644 --- a/src/migrations.lua +++ b/src/migrations.lua @@ -1586,4 +1586,25 @@ return { db.query(grants_sql) db.query("COMMIT") end, + + [1771350000] = function() + db.query("BEGIN") + + -- Drop old v3 sessions table and create new v4-compatible table. + -- lua-resty-session v4 uses a completely different schema. + db.query("DROP TRIGGER IF EXISTS sessions_stamp_record ON sessions") + db.query("DROP TABLE sessions") + db.query([[ + CREATE TABLE sessions( + sid TEXT PRIMARY KEY, + name TEXT, + data TEXT, + exp TIMESTAMP WITH TIME ZONE + ) + ]]) + db.query("CREATE INDEX ON sessions(exp)") + + db.query(grants_sql) + db.query("COMMIT") + end, } diff --git a/src/resty/session/ciphers/api_umbrella.lua b/src/resty/session/ciphers/api_umbrella.lua deleted file mode 100644 index afc849a34..000000000 --- a/src/resty/session/ciphers/api_umbrella.lua +++ /dev/null @@ -1,27 +0,0 @@ -local encryptor = require "api-umbrella.utils.encryptor" - -local _M = {} -_M.__index = _M - -function _M.new() - return setmetatable({}, _M) -end - -function _M.encrypt(_, data, _, id, auth_data) - local iv = string.sub(id, 1, 12) - local encrypted, _ = encryptor.encrypt(data, auth_data, { - iv = iv, - base64 = false, - }) - - return encrypted -end - -function _M.decrypt(_, encrypted_data, _, id, auth_data) - local iv = string.sub(id, 1, 12) - return encryptor.decrypt(encrypted_data, iv, auth_data, { - base64 = false, - }) -end - -return _M diff --git a/src/resty/session/hmac/api_umbrella.lua b/src/resty/session/hmac/api_umbrella.lua deleted file mode 100644 index e23836805..000000000 --- a/src/resty/session/hmac/api_umbrella.lua +++ /dev/null @@ -1,13 +0,0 @@ --- Hash resty-session values using HMAC SHA-256. --- --- resty-session defaults to HMAC SHA1 (since it's built in to OpenResty), but --- we'll use sha256 in resty-session to better align with the rest of our --- default hmac usage throughout our app. - -local hmac = require "resty.nettle.hmac" - -return function(secret_key, value) - local hmac_sha256 = hmac.sha256.new(secret_key) - hmac_sha256:update(value) - return hmac_sha256:digest() -end diff --git a/src/resty/session/identifiers/api_umbrella.lua b/src/resty/session/identifiers/api_umbrella.lua deleted file mode 100644 index ba219ebf7..000000000 --- a/src/resty/session/identifiers/api_umbrella.lua +++ /dev/null @@ -1,11 +0,0 @@ -local random_token = require "api-umbrella.utils.random_token" - -local defaults = { - length = 40 -} - -return function(session) - local config = session.random or defaults - local length = tonumber(config.length, 10) or defaults.length - return random_token(length) -end diff --git a/src/resty/session/serializers/api_umbrella.lua b/src/resty/session/serializers/api_umbrella.lua deleted file mode 100644 index dfda11055..000000000 --- a/src/resty/session/serializers/api_umbrella.lua +++ /dev/null @@ -1,16 +0,0 @@ -local json_encode_sorted_keys = require "api-umbrella.utils.json_encode_sorted_keys" -local json_decode = require("cjson.safe").decode - --- Replace lua-resty-session's default JSON serializer with one that serializes --- in a stable, sorted manner. --- --- The default serializer otherwise may return the same table in a different --- order each time it is serialized, which can cause issues with the encryption --- signatures or tagging. Without sorting the output by the keys, the same --- underlying table may be output in different ways on each serialization call, --- which can cause invalid signature errors when the session is updated but not --- actually changed (eg, when the inactive time is touched). -return { - serialize = json_encode_sorted_keys, - deserialize = json_decode, -} diff --git a/src/resty/session/storage/api_umbrella_db.lua b/src/resty/session/storage/api_umbrella_db.lua deleted file mode 100644 index 451c93c76..000000000 --- a/src/resty/session/storage/api_umbrella_db.lua +++ /dev/null @@ -1,51 +0,0 @@ -local db = require "lapis.db" -local encode_bytea = require("pgmoon").Postgres.encode_bytea - -local _M = {} -_M.__index = _M - -function _M.new(session) - local self = { - encode = session.encoder.encode, - decode = session.encoder.decode, - } - - return setmetatable(self, _M) -end - -function _M.open(_, id_encoded) - local data - local res = db.query("SELECT data_encrypted FROM sessions WHERE id_hash = ? AND expires_at >= now()", id_encoded) - if res and res[1] and res[1]["data_encrypted"] then - data = res[1]["data_encrypted"] - end - - return data -end - -function _M.start() - return true -end - -function _M:save(id_encoded, ttl, data) - local id = self.decode(id_encoded) - local iv = string.sub(id, 1, 12) - - db.query("INSERT INTO sessions(id_hash, expires_at, data_encrypted, data_encrypted_iv) VALUES(?, now() + interval ?, ?, ?) ON CONFLICT (id_hash) DO UPDATE SET expires_at = EXCLUDED.expires_at, data_encrypted = EXCLUDED.data_encrypted, data_encrypted_iv = EXCLUDED.data_encrypted_iv", id_encoded, ttl .. " seconds", db.raw(encode_bytea(nil, data)), iv) - return true -end - -function _M.close() - return true -end - -function _M.destroy(_, id_encoded) - db.query("DELETE FROM sessions WHERE id_hash = ?", id_encoded) -end - -function _M.ttl(_, id_encoded, ttl) - db.query("UPDATE sessions SET expires_at = now() + interval ? WHERE id_hash = ?", ttl .. " seconds", id_encoded) - return true -end - -return _M diff --git a/test/admin_ui/login/test_logout.rb b/test/admin_ui/login/test_logout.rb index bd4d61c48..7c303ac9e 100644 --- a/test/admin_ui/login/test_logout.rb +++ b/test/admin_ui/login/test_logout.rb @@ -32,7 +32,7 @@ def test_logout_requires_csrf_or_admin_token assert_response_code(302, response) assert_equal("https://127.0.0.1:9081/admin/#/after-logout", response.headers.fetch("Location")) set_cookies = Array(response.headers["Set-Cookie"]).join("; ") - assert_match("_api_umbrella_session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT", set_cookies) + assert_match(/_api_umbrella_session=;.*Expires=Thu, 01 Jan 1970 00:00:01 GMT/, set_cookies) data = parse_admin_session_client_cookie(response.headers["Set-Cookie"]) assert_equal("Signed out successfully.", data["flash"]["info"]["message"]) diff --git a/test/support/api_umbrella_test_helpers/admin_auth.rb b/test/support/api_umbrella_test_helpers/admin_auth.rb index 69a94d523..07874c4b7 100644 --- a/test/support/api_umbrella_test_helpers/admin_auth.rb +++ b/test/support/api_umbrella_test_helpers/admin_auth.rb @@ -8,6 +8,15 @@ module AdminAuth # a hard-coded user agent when we're pre-seeding the session cookie value. STATIC_USER_AGENT = "TestStaticUserAgent".freeze + # lua-resty-session v4 binary header layout constants. + # Header is 82 bytes: [1B type][2B flags][32B sid][5B creation_time] + # [4B rolling_offset][3B data_size][16B tag][3B idling_offset][16B mac] + V4_HEADER_SIZE = 82 + V4_HEADER_SID_OFFSET = 3 + V4_HEADER_SID_SIZE = 32 + V4_HEADER_PRE_TAG_SIZE = 47 # bytes before AES-GCM tag + V4_HEADER_TAG_SIZE = 16 + include ApiUmbrellaTestHelpers::Selenium def admin_login(admin = nil) @@ -231,125 +240,183 @@ def session_base64_decode(value) Base64.urlsafe_decode64(value) end - def encrypt_session_cookie(data) - id = SecureRandom.hex(20) - id_encoded = session_base64_encode(id) - iv = id[0, 12] - expires = Time.now.to_i + 3600 - data_serialized = MultiJson.dump(data) - hmac_data_key = OpenSSL::HMAC.digest("sha256", $config["secret_key"], [ - id, - expires, - ].join("")) - hmac_data = OpenSSL::HMAC.digest("sha256", hmac_data_key, [ - id, - expires, - data_serialized, - STATIC_USER_AGENT, - "http", - ].join("")) - auth_data = [ - STATIC_USER_AGENT, - "http", + # Build a lua-resty-session v4 cookie header and encrypt data using v4's + # binary cookie format. Returns [header_binary, ciphertext_binary]. + # + # The 82-byte header structure: + # [1B type][2B flags][32B sid][5B creation_time][4B rolling_offset] + # [3B data_size][16B tag][3B idling_offset][16B mac] + # + # The encryption uses HKDF-derived AES-256-GCM keys. + def build_v4_session(data, flags: 0) + ikm = Digest::SHA256.digest($config.fetch("secret_key")) + cookie_type = 1 + sid = SecureRandom.bytes(32) + creation_time = Time.now.to_i + rolling_offset = 0 + idling_offset = 0 + + # Derive AES-256-GCM key and IV from the SID using HKDF + sid_key = OpenSSL::KDF.hkdf(ikm, salt: "", info: "encryption:#{sid}", length: 44, hash: "SHA256") + aes_key = sid_key[0, 32] + iv = sid_key[32, 12] + + # Compute data_size as the base64url-encoded length of the ciphertext. + # We need to encrypt first to know the exact size, but since AES-GCM + # output length equals input length for the ciphertext portion, we can + # predict it: base64url length of data.length bytes. + # Actually, we need to encrypt to get the tag, so we build the header + # in two passes: first without tag/mac, encrypt, then fill in tag/mac. + + # Build partial header (up to and including data_size, before tag) + # This is the AAD for AES-GCM encryption. + data_size = session_base64_encode(data).length + partial_header = [ + [cookie_type].pack("C"), # 1 byte + [flags].pack("v"), # 2 bytes little-endian uint16 + sid, # 32 bytes + [creation_time].pack("Q<")[0, 5], # 5 bytes LE + [rolling_offset].pack("V"), # 4 bytes LE + [data_size].pack("V")[0, 3], # 3 bytes LE ].join("") - data_encrypted = Encryptor.encrypt({ - :value => data_serialized, - :iv => iv, - :key => Digest::SHA256.digest($config["secret_key"]), - :auth_data => auth_data, - }) + # Encrypt session data with AES-256-GCM + cipher = OpenSSL::Cipher.new("aes-256-gcm") + cipher.encrypt + cipher.key = aes_key + cipher.iv = iv + cipher.auth_data = partial_header + ciphertext = cipher.update(data) + ciphertext << cipher.final + tag = cipher.auth_tag - Session.create!({ - :id_hash => id_encoded, - :expires_at => Time.at(expires).utc, - :data_encrypted => data_encrypted, - :data_encrypted_iv => iv, - }) + # Complete header: partial_header + tag + idling_offset + header_for_mac = partial_header + tag + [idling_offset].pack("V")[0, 3] + + # Compute HMAC-SHA256 MAC over the header (truncated to 16 bytes) + mac_key = OpenSSL::KDF.hkdf(ikm, salt: "", info: "authentication:#{sid}", length: 32, hash: "SHA256") + mac = OpenSSL::HMAC.digest("sha256", mac_key, header_for_mac)[0, 16] - [ - id_encoded, - expires, - session_base64_encode(hmac_data), - ].join("|") + full_header = header_for_mac + mac + + { + header: full_header, + ciphertext: ciphertext, + sid: sid, + creation_time: creation_time, + } end - def decrypt_session_cookie(cookie_value) - parts = cookie_value.split("|") - id_encoded = parts[0] - auth_data = [ - STATIC_USER_AGENT, - "http", - ].join("") + # Serialize session data the way lua-resty-session v4 stores it internally: + # [[data_dict, audience]] where audience defaults to "default" + def v4_session_data(data) + MultiJson.dump([[data, "default"]]) + end + + def encrypt_session_cookie(data) + data_serialized = v4_session_data(data) + # FLAG_STORAGE = 0x0001 indicates server-side storage is used + result = build_v4_session(data_serialized, flags: 0x0001) - session = Session.find_by(:id_hash => id_encoded) + # For DB-backed sessions, cookie contains only the header + cookie_header = session_base64_encode(result[:header]) - data_serialized = Encryptor.decrypt({ - :value => session.data_encrypted, - :iv => session.data_encrypted_iv, - :key => Digest::SHA256.digest($config["secret_key"]), - :auth_data => auth_data, + # Store encrypted data in database + sid_encoded = session_base64_encode(result[:sid]) + ciphertext_encoded = session_base64_encode(result[:ciphertext]) + db_data = MultiJson.dump([ciphertext_encoded]) + ttl = 12 * 60 * 60 # 12 hours, matches absolute_timeout + exp = Time.at(result[:creation_time] + ttl).utc + + Session.create!({ + :sid => sid_encoded, + :name => "_api_umbrella_session", + :data => db_data, + :exp => exp, }) - MultiJson.load(data_serialized) + cookie_header end - def encrypt_session_client_cookie(data) - id = SecureRandom.hex(20) - iv = id[0, 12] - id_encoded = session_base64_encode(id) - expires = Time.now.to_i + 3600 - data_serialized = MultiJson.dump(data) - hmac_data_key = OpenSSL::HMAC.digest("sha256", $config["secret_key"], [ - id, - expires, - ].join("")) - hmac_data = OpenSSL::HMAC.digest("sha256", hmac_data_key, [ - id, - expires, - data_serialized, - STATIC_USER_AGENT, - "http", - ].join("")) - auth_data = [ - STATIC_USER_AGENT, - "http", - ].join("") + def decrypt_session_cookie(cookie_value) + # In v4, the DB-backed cookie is just the base64url-encoded header. + # Extract the SID from the header to look up the DB record. + header = session_base64_decode(cookie_value) + sid = header[V4_HEADER_SID_OFFSET, V4_HEADER_SID_SIZE] + sid_encoded = session_base64_encode(sid) + + session = Session.find_by(:sid => sid_encoded) + + # DB data is a JSON array: [""] + db_data = MultiJson.load(session.data) + ciphertext = session_base64_decode(db_data[0]) + + # Derive the decryption key from the SID + ikm = Digest::SHA256.digest($config["secret_key"]) + sid_key = OpenSSL::KDF.hkdf(ikm, salt: "", info: "encryption:#{sid}", length: 44, hash: "SHA256") + aes_key = sid_key[0, 32] + iv = sid_key[32, 12] + + # The AAD is the bytes before the AES-GCM tag + aad = header[0, V4_HEADER_PRE_TAG_SIZE] + tag = header[V4_HEADER_PRE_TAG_SIZE, V4_HEADER_TAG_SIZE] + + decipher = OpenSSL::Cipher.new("aes-256-gcm") + decipher.decrypt + decipher.key = aes_key + decipher.iv = iv + decipher.auth_data = aad + decipher.auth_tag = tag + plaintext = decipher.update(ciphertext) + plaintext << decipher.final + + # v4 data format: [[data_dict, audience]] + parsed = MultiJson.load(plaintext) + parsed[0][0] + end - data_encrypted = Encryptor.encrypt({ - :value => data_serialized, - :iv => iv, - :key => Digest::SHA256.digest($config["secret_key"]), - :auth_data => auth_data, - }) + def encrypt_session_client_cookie(data) + data_serialized = v4_session_data(data) + # No FLAG_STORAGE for cookie-only sessions + result = build_v4_session(data_serialized, flags: 0) - [ - id_encoded, - expires, - session_base64_encode(data_encrypted), - session_base64_encode(hmac_data), - ].join("|") + # For cookie-only sessions, cookie contains header + ciphertext + session_base64_encode(result[:header]) + session_base64_encode(result[:ciphertext]) end def decrypt_session_client_cookie(cookie_value) - parts = cookie_value.split("|") - id_encoded = parts[0] - id = session_base64_decode(id_encoded) - iv = id[0, 12] - data = session_base64_decode(parts[2]) - auth_data = [ - STATIC_USER_AGENT, - "http", - ].join("") - - data_serialized = Encryptor.decrypt({ - :value => data, - :iv => iv, - :key => Digest::SHA256.digest($config["secret_key"]), - :auth_data => auth_data, - }) - - MultiJson.load(data_serialized) + # The cookie value is base64url(header) + base64url(ciphertext). + # The header is always V4_HEADER_SIZE bytes raw. + header_b64_len = session_base64_encode("\x00" * V4_HEADER_SIZE).length + header_b64 = cookie_value[0, header_b64_len] + ciphertext_b64 = cookie_value[header_b64_len..] + + header = session_base64_decode(header_b64) + ciphertext = session_base64_decode(ciphertext_b64) + sid = header[V4_HEADER_SID_OFFSET, V4_HEADER_SID_SIZE] + + # Derive the decryption key from the SID + ikm = Digest::SHA256.digest($config["secret_key"]) + sid_key = OpenSSL::KDF.hkdf(ikm, salt: "", info: "encryption:#{sid}", length: 44, hash: "SHA256") + aes_key = sid_key[0, 32] + iv = sid_key[32, 12] + + # The AAD is the bytes before the AES-GCM tag + aad = header[0, V4_HEADER_PRE_TAG_SIZE] + tag = header[V4_HEADER_PRE_TAG_SIZE, V4_HEADER_TAG_SIZE] + + decipher = OpenSSL::Cipher.new("aes-256-gcm") + decipher.decrypt + decipher.key = aes_key + decipher.iv = iv + decipher.auth_data = aad + decipher.auth_tag = tag + plaintext = decipher.update(ciphertext) + plaintext << decipher.final + + # v4 data format: [[data_dict, audience]] + parsed = MultiJson.load(plaintext) + parsed[0][0] end end end diff --git a/test/support/models/session.rb b/test/support/models/session.rb index fe82664d1..a7863f5c9 100644 --- a/test/support/models/session.rb +++ b/test/support/models/session.rb @@ -1,3 +1,3 @@ class Session < ApplicationRecord - self.primary_key = "id_hash" + self.primary_key = "sid" end