Skip to content
Open
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
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"},
},
},
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"
}
}
},
"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