diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 1aefa0c6975d..4650542076e5 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -17,6 +17,8 @@ local core = require("apisix.core") local http = require("resty.http") local openssl_mac = require("resty.openssl.mac") +local resty_sha256 = require("resty.sha256") +local resty_string = require("resty.string") local bit = require("bit") local ngx = ngx local ngx_re_match = ngx.re.match @@ -24,12 +26,15 @@ local ngx_encode_base64 = ngx.encode_base64 local ngx_decode_base64 = ngx.decode_base64 local CAS_REQUEST_URI = "CAS_REQUEST_URI" -local COOKIE_NAME = "CAS_SESSION" +local COOKIE_PREFIX = "CAS_SESSION_" +local ENTRY_SEP = "|" local SESSION_LIFETIME = 3600 local STORE_NAME = "cas_sessions" local store = ngx.shared[STORE_NAME] +local session_opts_cache = {} + local plugin_name = "cas-auth" local schema = { @@ -131,14 +136,65 @@ local function uri_without_ticket(conf, ctx) ctx.var.server_port .. conf.cas_callback_uri end -local function get_session_id(ctx) - return ctx.var["cookie_" .. COOKIE_NAME] +-- Derive per-route cookie name and session-payload fingerprint from the +-- fields that define a CAS trust context (idp_uri + cas_callback_uri). +-- Memoised so the SHA-256 only runs once per distinct configuration. +local function session_opts(conf) + local fp_input = conf.idp_uri .. ENTRY_SEP .. conf.cas_callback_uri + local cached = session_opts_cache[fp_input] + if cached then + return cached + end + local sha256 = resty_sha256:new() + sha256:update(fp_input) + local digest_hex = resty_string.to_hex(sha256:final()) + cached = { + cookie_name = COOKIE_PREFIX .. digest_hex, + fingerprint = digest_hex, + } + session_opts_cache[fp_input] = cached + return cached +end + +local function pack_entry(fingerprint, user) + return fingerprint .. ENTRY_SEP .. user +end + +-- Returns (fingerprint, user) for entries written by pack_entry, or +-- (nil, nil) for legacy entries that pre-date per-config binding. +local function unpack_entry(entry) + if not entry then return nil, nil end + local sep = entry:find(ENTRY_SEP, 1, true) + if not sep then return nil, nil end + return entry:sub(1, sep - 1), entry:sub(sep + 1) end local function set_our_cookie(conf, name, val) core.response.add_header("Set-Cookie", name .. "=" .. val .. cookie_attrs(conf)) end +-- nginx's $cookie_ variable doesn't reliably expose cookies whose names +-- exceed certain lengths in older OpenResty builds (the per-config cookie name +-- is "CAS_SESSION_"). Parse the raw Cookie header as a fallback. +local function get_cookie(ctx, name) + local val = ctx.var["cookie_" .. name] + if val ~= nil then + return val + end + local cookie_header = ctx.var.http_cookie + if not cookie_header then + return nil + end + local prefix = name .. "=" + for piece in (cookie_header .. ";"):gmatch("([^;]+);") do + piece = piece:gsub("^%s+", "") + if piece:sub(1, #prefix) == prefix then + return piece:sub(#prefix + 1) + end + end + return nil +end + local function compute_hmac(secret, val) local m, err = openssl_mac.new(secret, "HMAC", nil, "sha256") if not m then return nil, err end @@ -208,27 +264,43 @@ local function first_access(conf, ctx) return ngx.HTTP_MOVED_TEMPORARILY end -local function with_session_id(conf, ctx, session_id) - -- does the cookie exist in our store? - local user = store:get(session_id) - if user == nil then - set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0") +local function with_session_id(conf, ctx, opts, session_id) + -- Namespacing the store key with the per-config fingerprint keeps + -- ticket strings from different IdPs from colliding in cas_sessions. + local key = opts.fingerprint .. ":" .. session_id + local entry = store:get(key) + if entry == nil then + set_our_cookie(conf, opts.cookie_name, "deleted; Max-Age=0") return first_access(conf, ctx) - else - -- refresh the TTL - store:set(session_id, user, SESSION_LIFETIME) - core.log.info("cas-auth: session refreshed") end + + local stored_fp = unpack_entry(entry) + if stored_fp ~= opts.fingerprint then + -- session was issued under a different CAS configuration; do not honour + set_our_cookie(conf, opts.cookie_name, "deleted; Max-Age=0") + return first_access(conf, ctx) + end + + local ok, err, forcible = store:set(key, entry, SESSION_LIFETIME) + if not ok then + core.log.error("cas-auth: failed to refresh session ttl: ", err or "unknown") + return + end + if forcible then + core.log.warn("cas-auth: session refresh caused forcible eviction") + end + core.log.info("cas-auth: session refreshed") end -local function set_store_and_cookie(conf, session_id, user) - -- place cookie into cookie store - local success, err, forcible = store:add(session_id, user, SESSION_LIFETIME) +local function set_store_and_cookie(conf, opts, session_id, user) + local entry = pack_entry(opts.fingerprint, user) + local key = opts.fingerprint .. ":" .. session_id + local success, err, forcible = store:add(key, entry, SESSION_LIFETIME) if success then if forcible then core.log.info("CAS cookie store is out of memory") end - set_our_cookie(conf, COOKIE_NAME, session_id) + set_our_cookie(conf, opts.cookie_name, session_id) else if err == "no memory" then core.log.emerg("CAS cookie store is out of memory") @@ -265,32 +337,34 @@ local function validate(conf, ctx, ticket) end local function validate_with_cas(conf, ctx, ticket) + local request_uri = verify_value(conf.cookie.secret, + ctx.var["cookie_" .. CAS_REQUEST_URI]) + if not request_uri or not is_safe_redirect(request_uri) then + core.log.warn("cas-auth: callback rejected, missing or invalid initiation cookie") + return ngx.HTTP_UNAUTHORIZED, {message = "invalid callback state"} + end + local user = validate(conf, ctx, ticket) - if user and set_store_and_cookie(conf, ticket, user) then - local request_uri = verify_value(conf.cookie.secret, - ctx.var["cookie_" .. CAS_REQUEST_URI]) + local opts = session_opts(conf) + if user and set_store_and_cookie(conf, opts, ticket, user) then set_our_cookie(conf, CAS_REQUEST_URI, "deleted; Max-Age=0") - if not is_safe_redirect(request_uri) then - core.log.warn("cas-auth: rejected unsafe redirect target, falling back to /") - request_uri = "/" - end core.log.info("cas-auth: validation succeeded for user=", user) core.response.set_header("Location", request_uri) return ngx.HTTP_MOVED_TEMPORARILY - else - return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"} end + return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"} end local function logout(conf, ctx) - local session_id = get_session_id(ctx) + local opts = session_opts(conf) + local session_id = get_cookie(ctx, opts.cookie_name) if session_id == nil then return ngx.HTTP_UNAUTHORIZED end core.log.info("cas-auth: logout invoked") - store:delete(session_id) - set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0") + store:delete(opts.fingerprint .. ":" .. session_id) + set_our_cookie(conf, opts.cookie_name, "deleted; Max-Age=0") core.response.set_header("Location", conf.idp_uri .. "/logout") return ngx.HTTP_MOVED_TEMPORARILY @@ -313,16 +387,19 @@ function _M.access(conf, ctx) {message = "invalid logout request from IdP, no ticket"} end core.log.info("cas-auth: SLO request received from IdP") - local session_id = ticket - local user = store:get(session_id) - if user then - store:delete(session_id) - core.log.info("cas-auth: SLO session deleted for user=", user) + local opts = session_opts(conf) + local key = opts.fingerprint .. ":" .. ticket + local entry = store:get(key) + if entry then + store:delete(key) + local _, user = unpack_entry(entry) + core.log.info("cas-auth: SLO session deleted for user=", user or "") end else - local session_id = get_session_id(ctx) + local opts = session_opts(conf) + local session_id = get_cookie(ctx, opts.cookie_name) if session_id ~= nil then - return with_session_id(conf, ctx, session_id) + return with_session_id(conf, ctx, opts, session_id) end local ticket = ctx.var.arg_ticket diff --git a/t/plugin/cas-auth.t b/t/plugin/cas-auth.t index ba07731e8d09..aec63ffd37d3 100644 --- a/t/plugin/cas-auth.t +++ b/t/plugin/cas-auth.t @@ -481,3 +481,319 @@ passed --- response_body_like ^302 .*service=https%3A%2F%2Fapp\.example\.com%2Fcas_callback.*$ + + + +=== TEST 14: add route for callback initiation-cookie gate +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + local code, body = t('/apisix/admin/routes/cas-gate', + ngx.HTTP_PUT, + [[{ + "methods": ["GET", "POST"], + "host": "127.0.0.3", + "plugins": { + "cas-auth": { + "idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas", + "cas_callback_uri": "/cas_callback", + "logout_uri": "/logout", + "cookie": { + "secret": "0123456789abcdef0123456789abcdef", + "secure": false + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/*" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 15: callback without initiation cookie returns 401 and creates no session +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port + .. "/cas_callback?ticket=ST-test" + + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Host"] = "127.0.0.3", + } + }) + if not res then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + + ngx.say(res.status) + + local set_cookie = res.headers['Set-Cookie'] + local has_session = false + if type(set_cookie) == "string" then + if set_cookie:find("^CAS_SESSION_") then + has_session = true + end + elseif type(set_cookie) == "table" then + for _, c in ipairs(set_cookie) do + if c:find("^CAS_SESSION_") then + has_session = true + break + end + end + end + ngx.say("session_cookie_set=", tostring(has_session)) + + -- No shared-dict entry should have been written for ST-test + -- under any configuration's fingerprint namespace. + local in_store = false + for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do + if k:find(":ST-test", 1, true) then + in_store = true + break + end + end + ngx.say("session_in_store=", tostring(in_store)) + } + } +--- response_body +401 +session_cookie_set=false +session_in_store=false + + + +=== TEST 16: callback with invalid initiation cookie returns 401 and creates no session +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port + .. "/cas_callback?ticket=ST-test" + + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Host"] = "127.0.0.3", + ["Cookie"] = "CAS_REQUEST_URI=not-a-valid-signed-value", + } + }) + if not res then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + + ngx.say(res.status) + + local set_cookie = res.headers['Set-Cookie'] + local has_session = false + if type(set_cookie) == "string" then + if set_cookie:find("^CAS_SESSION_") then + has_session = true + end + elseif type(set_cookie) == "table" then + for _, c in ipairs(set_cookie) do + if c:find("^CAS_SESSION_") then + has_session = true + break + end + end + end + ngx.say("session_cookie_set=", tostring(has_session)) + + -- No shared-dict entry should have been written for ST-test + -- under any configuration's fingerprint namespace. + local in_store = false + for _, k in ipairs(ngx.shared.cas_sessions:get_keys(0)) do + if k:find(":ST-test", 1, true) then + in_store = true + break + end + end + ngx.say("session_in_store=", tostring(in_store)) + } + } +--- response_body +401 +session_cookie_set=false +session_in_store=false + + + +=== TEST 17: Add dedicated routes for the per-config scoping test +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + + -- Use priority=10 so these routes win over the no-host catch-all + -- registered in earlier tests (cas-abs), and unique hosts so they + -- don't collide with cas1/cas2. + local function put(id, host, cb) + local code, body = t('/apisix/admin/routes/' .. id, + ngx.HTTP_PUT, + string.format([[{ + "methods": ["GET", "POST"], + "host": %q, + "priority": 10, + "plugins": { + "cas-auth": { + "idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas", + "cas_callback_uri": %q, + "logout_uri": "/logout", + "cookie": { + "secret": "0123456789abcdef0123456789abcdef", + "secure": false + } + } + }, + "upstream": { + "nodes": {"127.0.0.1:1980": 1}, + "type": "roundrobin" + }, + "uri": "/*" + }]], host, cb)) + if code >= 300 then + ngx.status = code + ngx.say(body) + return false + end + return true + end + + if not put("cas-scope-a", "127.0.0.10", "/cas_callback") then return end + if not put("cas-scope-b", "127.0.0.11", "/cas_callback_alt") then return end + ngx.say("passed") + } + } +--- response_body +passed + + + +=== TEST 18: sessions from one CAS configuration are not honoured under another +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local resty_sha256 = require("resty.sha256") + local str = require("resty.string") + + -- Recompute the per-config fingerprint here rather than exposing + -- the plugin's session_opts helper. Algorithm matches the plugin. + local function fingerprint(idp, cb) + local s = resty_sha256:new() + s:update(idp .. "|" .. cb) + return str.to_hex(s:final()) + end + + local idp = "http://127.0.0.1:8080/realms/test/protocol/cas" + local fp_a = fingerprint(idp, "/cas_callback") + local fp_b = fingerprint(idp, "/cas_callback_alt") + assert(fp_a ~= fp_b, "two configs must yield different fingerprints") + + -- Plant a session as the plugin would: store key namespaced by the + -- fingerprint, value of "|". This exercises the plugin's + -- session-read path (with_session_id -> store:get -> unpack_entry + -- -> fingerprint check) on scope-a. + local ticket = "ST-scope-test-" .. tostring(ngx.now()) + local key_a = fp_a .. ":" .. ticket + local ok, err = ngx.shared.cas_sessions:set(key_a, fp_a .. "|alice", 60) + assert(ok, "plant failed: " .. tostring(err)) + + local httpc = http.new() + local base = "http://127.0.0.1:" .. ngx.var.server_port + + -- Route scope-a (host 127.0.0.10) honours its own session. + local res, err2 = httpc:request_uri(base .. "/uri", { + method = "GET", + headers = { + ["Host"] = "127.0.0.10", + ["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket, + }, + }) + assert(res, "scope-a request failed: " .. tostring(err2)) + assert(res.status == 200, + "scope-a should honour its own session, got status " .. res.status) + + -- Same cookie sent to scope-b (different cas_callback_uri, different + -- fingerprint): scope-b looks for CAS_SESSION_, doesn't find + -- it, redirects to its own IdP. + res, err2 = httpc:request_uri(base .. "/uri", { + method = "GET", + headers = { + ["Host"] = "127.0.0.11", + ["Cookie"] = "CAS_SESSION_" .. fp_a .. "=" .. ticket, + }, + }) + assert(res, "scope-b request failed: " .. tostring(err2)) + assert(res.status == 302, + "scope-b must not honour foreign cookie name, got " + .. res.status) + + -- A forged cookie under scope-b's own name pointing at scope-a's + -- ticket: the namespaced store key under fp_b doesn't exist, + -- so the request still falls through to first_access. + res, err2 = httpc:request_uri(base .. "/uri", { + method = "GET", + headers = { + ["Host"] = "127.0.0.11", + ["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket, + }, + }) + assert(res, "scope-b forged-cookie request failed: " .. tostring(err2)) + assert(res.status == 302, + "scope-b must not honour foreign session payload, got " + .. res.status) + + -- Plant an entry under scope-b's namespaced key but with scope-a's + -- fingerprint inside the stored value. This is the only path that + -- reaches the in-value fingerprint check in with_session_id: + -- store:get finds the entry, but unpack_entry returns fp_a while + -- the route's opts.fingerprint is fp_b -> first_access (302). + local key_b_forged = fp_b .. ":" .. ticket + local ok2, err3 = ngx.shared.cas_sessions:set(key_b_forged, + fp_a .. "|alice", 60) + assert(ok2, "forged plant failed: " .. tostring(err3)) + + res, err2 = httpc:request_uri(base .. "/uri", { + method = "GET", + headers = { + ["Host"] = "127.0.0.11", + ["Cookie"] = "CAS_SESSION_" .. fp_b .. "=" .. ticket, + }, + }) + assert(res, "scope-b fingerprint-mismatch request failed: " .. tostring(err2)) + assert(res.status == 302, + "scope-b must reject a stored entry whose fingerprint does not match, got " + .. res.status) + + ngx.shared.cas_sessions:delete(key_a) + ngx.shared.cas_sessions:delete(key_b_forged) + ngx.say("passed") + } + } +--- response_body +passed