Skip to content
136 changes: 111 additions & 25 deletions apisix/plugins/cas-auth.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@
----
local core = require("apisix.core")
local http = require("resty.http")
local hmac = require("resty.openssl.hmac")
local bit = require("bit")
local ngx = ngx
local ngx_re_match = ngx.re.match
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_PARAMS = "; Path=/; HttpOnly"
local SESSION_LIFETIME = 3600
local STORE_NAME = "cas_sessions"

Expand All @@ -35,19 +38,38 @@ local schema = {
idp_uri = {type = "string"},
cas_callback_uri = {type = "string"},
logout_uri = {type = "string"},
cookie = {
type = "object",
properties = {
secret = {type = "string", minLength = 32},
secure = {type = "boolean", default = true},
samesite = {type = "string", enum = {"Lax", "None"}, default = "Lax"},
},
required = {"secret"},
Comment thread
AlinsRan marked this conversation as resolved.
},
},
encrypt_fields = {"cookie.secret"},
required = {
"idp_uri", "cas_callback_uri", "logout_uri"
"idp_uri", "cas_callback_uri", "logout_uri", "cookie"
}
}

local _M = {
version = 0.1,
priority = 2597,
name = plugin_name,
schema = schema
schema = schema,
}

local function cookie_attrs(conf)
local attrs = "; Path=/; HttpOnly"
if conf.cookie.secure then
attrs = attrs .. "; Secure"
end
attrs = attrs .. "; SameSite=" .. conf.cookie.samesite
return attrs
end

function _M.check_schema(conf)
local check = {"idp_uri"}
core.utils.check_https(check, conf, plugin_name)
Expand All @@ -63,41 +85,101 @@ local function get_session_id(ctx)
return ctx.var["cookie_" .. COOKIE_NAME]
end

local function set_our_cookie(name, val)
core.response.add_header("Set-Cookie", name .. "=" .. val .. COOKIE_PARAMS)
local function set_our_cookie(conf, name, val)
core.response.add_header("Set-Cookie", name .. "=" .. val .. cookie_attrs(conf))
end

local function compute_hmac(secret, val)
local h, err = hmac.new(secret, "sha256")
if not h then return nil, err end
local ok, err2 = h:update(val)
if not ok then return nil, err2 end
return h:final()
end

local function eq_const_time(a, b)
if #a ~= #b then return false end
local diff = 0
for i = 1, #a do
diff = bit.bor(diff, bit.bxor(a:byte(i), b:byte(i)))
end
return diff == 0
end

local function sign_value(secret, val)
local sig, err = compute_hmac(secret, val)
if not sig then
core.log.error("cas-auth: hmac sign failed: ", err)
return nil
end
return ngx_encode_base64(val, true) .. "." .. ngx_encode_base64(sig, true)
end

local function verify_value(secret, signed)
if not signed then return nil end
local dot = signed:find(".", 1, true)
if not dot then return nil end
local val = ngx_decode_base64(signed:sub(1, dot - 1))
local sig = ngx_decode_base64(signed:sub(dot + 1))
if not val or not sig then return nil end
local expected, err = compute_hmac(secret, val)
if not expected then
core.log.error("cas-auth: hmac verify failed: ", err)
return nil
end
if not eq_const_time(sig, expected) then return nil end
return val
end

local function is_safe_redirect(uri)
if not uri or uri == "" then return false end
if uri:sub(1, 1) ~= "/" then return false end
if uri:sub(1, 2) == "//" then return false end
if uri:find("\\", 1, true) then return false end
if uri:find("[\r\n]") then return false end
return true
end

-- Exposed for unit tests; not part of the plugin's public API.
_M._test_helpers = {
sign_value = sign_value,
verify_value = verify_value,
is_safe_redirect = is_safe_redirect,
}

local function first_access(conf, ctx)
local login_uri = conf.idp_uri .. "/login?" ..
ngx.encode_args({ service = uri_without_ticket(conf, ctx) })
core.log.info("first access: ", login_uri,
", cookie: ", ctx.var.http_cookie, ", request_uri: ", ctx.var.request_uri)
set_our_cookie(CAS_REQUEST_URI, ctx.var.request_uri)
core.log.info("cas-auth: redirecting unauthenticated request to IdP")
local signed = sign_value(conf.cookie.secret, ctx.var.request_uri)
if signed then
set_our_cookie(conf, CAS_REQUEST_URI, signed)
end
core.response.set_header("Location", login_uri)
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);
core.log.info("ticket=", session_id, ", user=", user)
local user = store:get(session_id)
if user == nil then
set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0")
set_our_cookie(conf, 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 for user=", user)
end
end

local function set_store_and_cookie(session_id, user)
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)
if success then
if forcible then
core.log.info("CAS cookie store is out of memory")
end
set_our_cookie(COOKIE_NAME, session_id)
set_our_cookie(conf, COOKIE_NAME, session_id)
else
if err == "no memory" then
core.log.emerg("CAS cookie store is out of memory")
Expand All @@ -119,12 +201,12 @@ local function validate(conf, ctx, ticket)

if res and res.status == ngx.HTTP_OK and res.body ~= nil then
if core.string.find(res.body, "<cas:authenticationSuccess>") then
local m = ngx_re_match(res.body, "<cas:user>(.*?)</cas:user>", "jo");
local m = ngx_re_match(res.body, "<cas:user>(.*?)</cas:user>", "jo")
if m then
return m[1]
end
else
core.log.info("CAS serviceValidate failed: ", res.body)
core.log.info("CAS serviceValidate did not return authenticationSuccess")
end
else
core.log.error("validate ticket failed: status=", (res and res.status),
Expand All @@ -135,11 +217,15 @@ end

local function validate_with_cas(conf, ctx, ticket)
local user = validate(conf, ctx, ticket)
if user and set_store_and_cookie(ticket, user) then
local request_uri = ctx.var["cookie_" .. CAS_REQUEST_URI]
set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0")
core.log.info("ticket: ", ticket,
", cookie: ", ctx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user)
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])
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
Expand All @@ -153,9 +239,9 @@ local function logout(conf, ctx)
return ngx.HTTP_UNAUTHORIZED
end

core.log.info("logout: ticket=", session_id, ", cookie=", ctx.var.http_cookie)
core.log.info("cas-auth: logout invoked")
store:delete(session_id)
set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0")
set_our_cookie(conf, COOKIE_NAME, "deleted; Max-Age=0")

core.response.set_header("Location", conf.idp_uri .. "/logout")
return ngx.HTTP_MOVED_TEMPORARILY
Expand All @@ -176,12 +262,12 @@ function _M.access(conf, ctx)
return ngx.HTTP_BAD_REQUEST,
{message = "invalid logout request from IdP, no ticket"}
end
core.log.info("Back-channel logout (SLO) from IdP: LogoutRequest: ", data)
core.log.info("cas-auth: SLO request received from IdP")
local session_id = ticket
local user = store:get(session_id);
local user = store:get(session_id)
if user then
store:delete(session_id)
core.log.info("SLO: user=", user, ", tocket=", ticket)
core.log.info("cas-auth: SLO session deleted for user=", user)
end
else
local session_id = get_session_id(ctx)
Expand Down
19 changes: 13 additions & 6 deletions docs/en/latest/plugins/cas-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ to do authentication, from the SP (service provider) perspective.

## Attributes

| Name | Type | Required | Description |
| ----------- | ----------- | ----------- | ----------- |
| `idp_uri` | string | True | URI of IdP. |
| `cas_callback_uri` | string | True | redirect uri used to callback the SP from IdP after login or logout. |
| `logout_uri` | string | True | logout uri to trigger logout. |
| Name | Type | Required | Default | Description |
| ----------- | ----------- | ----------- | ----------- | ----------- |
| `idp_uri` | string | True | | URI of IdP. |
| `cas_callback_uri` | string | True | | redirect uri used to callback the SP from IdP after login or logout. |
| `logout_uri` | string | True | | logout uri to trigger logout. |
| `cookie` | object | True | | configuration for the cookies the plugin issues during the CAS login flow. |
| `cookie.secret` | string | True | | secret (32+ characters) used to sign the request URI cookie. The same value must be configured on every APISIX node. Generate with e.g. `openssl rand -base64 48`. |
| `cookie.secure` | boolean | False | `true` | whether to set the `Secure` attribute on the issued cookies. Set to `false` only for deployments where the protected route is not served over HTTPS (e.g. internal-only or development environments). |
| `cookie.samesite` | string | False | `"Lax"` | value for the `SameSite` cookie attribute. Allowed values are `"Lax"` and `"None"`; `"Strict"` is intentionally not supported because it breaks the IdP→SP redirect when the IdP is on a different site. |

## Enable Plugin

Expand All @@ -64,7 +68,10 @@ curl http://127.0.0.1:9180/apisix/admin/routes/cas1 -H "X-API-KEY: $admin_key" -
"cas-auth": {
"idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas",
"cas_callback_uri": "/anything/cas_callback",
"logout_uri": "/anything/logout"
"logout_uri": "/anything/logout",
"cookie": {
"secret": "please-replace-with-a-32+-char-random-secret"
Comment thread
nic-6443 marked this conversation as resolved.
Outdated
}
}
},
"upstream": {
Expand Down
4 changes: 4 additions & 0 deletions t/lib/keycloak_cas.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ local default_opts = {
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,
},
}

function _M.get_default_opts()
Expand Down
Loading
Loading