From 05244c02c14c1d5d8c8cf547340333fa07e551ea Mon Sep 17 00:00:00 2001 From: kingluo Date: Fri, 16 Sep 2022 16:27:28 +0800 Subject: [PATCH 01/12] feat: add cas-auth plugin --- .github/workflows/build.yml | 4 + .github/workflows/centos7-ci.yml | 4 + apisix/cli/ngx_tpl.lua | 4 + apisix/plugins/cas-auth.lua | 207 ++++++++++++++++++++++++++++ ci/init-plugin-test-service.sh | 5 + ci/kcadm_configure_cas.sh | 37 +++++ ci/pod/docker-compose.plugin.yml | 22 +++ conf/config-default.yaml | 2 + docs/en/latest/config.json | 1 + t/APISIX.pm | 1 + t/admin/plugins.t | 1 + t/lib/keycloak_cas.lua | 215 +++++++++++++++++++++++++++++ t/plugin/cas-auth.t | 227 +++++++++++++++++++++++++++++++ 13 files changed, 730 insertions(+) create mode 100644 apisix/plugins/cas-auth.lua create mode 100644 ci/kcadm_configure_cas.sh create mode 100644 t/lib/keycloak_cas.lua create mode 100644 t/plugin/cas-auth.t diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d34c0330a5c..d33e9a0e957d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,6 +97,10 @@ jobs: rm -rf $(ls -1 --ignore=*.tgz --ignore=ci --ignore=t --ignore=utils --ignore=.github) tar zxvf ${{ steps.branch_env.outputs.fullname }} + - name: download keycloak cas provider + run: | + sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar + - name: Start CI env (FIRST_TEST) if: steps.test_env.outputs.type == 'first' run: | diff --git a/.github/workflows/centos7-ci.yml b/.github/workflows/centos7-ci.yml index 584ffac3bf20..e0aa25cc709c 100644 --- a/.github/workflows/centos7-ci.yml +++ b/.github/workflows/centos7-ci.yml @@ -93,6 +93,10 @@ jobs: docker run -itd -v /home/runner/work/apisix/apisix:/apisix --env TEST_FILE_SUB_DIR="$TEST_FILE_SUB_DIR" --name centos7Instance --net="host" --dns 8.8.8.8 --dns-search apache.org docker.io/centos:7 /bin/bash # docker exec centos7Instance bash -c "cp -r /tmp/apisix ./" + - name: download keycloak cas provider + run: | + sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar + - name: Start CI env (FIRST_TEST) if: steps.test_env.outputs.type == 'first' run: | diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 87c60b4b7c7c..a655efcfc934 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -287,6 +287,10 @@ http { lua_shared_dict introspection {* http.lua_shared_dict["introspection"] *}; # cache for JWT verification results {% end %} + {% if enabled_plugins["cas-auth"] then %} + lua_shared_dict cas_sessions {* http.lua_shared_dict["cas-auth"] *}; + {% end %} + {% if enabled_plugins["authz-keycloak"] then %} # for authz-keycloak lua_shared_dict access-tokens {* http.lua_shared_dict["access-tokens"] *}; # cache for service account access tokens diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua new file mode 100644 index 000000000000..70b93f2018d3 --- /dev/null +++ b/apisix/plugins/cas-auth.lua @@ -0,0 +1,207 @@ +-- +---- Licensed to the Apache Software Foundation (ASF) under one or more +---- contributor license agreements. See the NOTICE file distributed with +---- this work for additional information regarding copyright ownership. +---- The ASF licenses this file to You under the Apache License, Version 2.0 +---- (the "License"); you may not use this file except in compliance with +---- the License. You may obtain a copy of the License at +---- +---- http://www.apache.org/licenses/LICENSE-2.0 +---- +---- Unless required by applicable law or agreed to in writing, software +---- distributed under the License is distributed on an "AS IS" BASIS, +---- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +---- See the License for the specific language governing permissions and +---- limitations under the License. +---- +local core = require("apisix.core") +local http = require("resty.http") +local ngx = ngx + +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" + +local store = ngx.shared[STORE_NAME] + + +local plugin_name = "cas-auth" +local schema = { + type = "object", + properties = { + idp_uri = {type = "string"}, + cas_callback_uri = {type = "string"}, + logout_uri = {type = "string"}, + }, + required = { + "idp_uri", "cas_callback_uri", "logout_uri" + } +} + +local _M = { + version = 0.1, + priority = 2597, + name = plugin_name, + schema = schema +} + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + +local function to_table(v) + if v == nil then + return {} + elseif type(v) == "table" then + return v + else + return {v} + end +end + +local function set_cookie(cookie_str) + local h = to_table(ngx.header['Set-Cookie']) + table.insert(h, cookie_str) + ngx.header['Set-Cookie'] = h +end + +local function uri_without_ticket(conf) + return ngx.var.scheme .. "://" .. ngx.var.host .. ":" .. + ngx.var.server_port .. conf.cas_callback_uri +end + +local function get_session_id() + return ngx.var["cookie_" .. COOKIE_NAME] +end + +local function set_our_cookie(name, val) + set_cookie(name .. "=" .. val .. COOKIE_PARAMS) +end + +local function first_access(conf) + local login_uri = conf.idp_uri .. "/login?" .. + ngx.encode_args({ service = uri_without_ticket(conf) }) + ngx.log(ngx.INFO, "first access: ", login_uri, + ", cookie: ", ngx.var.http_cookie, ", request_uri: ", ngx.var.request_uri) + set_our_cookie(CAS_REQUEST_URI, ngx.var.request_uri) + ngx.redirect(login_uri, ngx.HTTP_MOVED_TEMPORARILY) +end + +local function with_session_id(conf, session_id) + -- does the cookie exist in our store? + local user = store:get(session_id); + ngx.log(ngx.INFO, "ticket=", session_id, ", user=", user) + if user == nil then + set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") + first_access(conf) + else + -- refresh the TTL + store:set(session_id, user, SESSION_LIFETIME) + end +end + +local function set_store_and_cookie(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 + ngx.log(ngx.INFO, "CAS cookie store is out of memory") + end + set_our_cookie(COOKIE_NAME, session_id) + else + if err == "no memory" then + ngx.log(ngx.EMERG, "CAS cookie store is out of memory") + elseif err == "exists" then + ngx.log(ngx.ERR, "Same CAS ticket validated twice, this should never happen!") + end + end + return success +end + +local function validate(conf, ticket) + -- send a request to CAS to validate the ticket + local httpc = http.new() + local res, err = httpc:request_uri(conf.idp_uri .. + "/serviceValidate", { query = { ticket = ticket, service = uri_without_ticket(conf) } }) + + if res and res.status == ngx.HTTP_OK and res.body ~= nil then + if string.find(res.body, "") then + local m = ngx.re.match(res.body, "(.*?)"); + if m then + return m[1] + end + else + ngx.log(ngx.INFO, "CAS serviceValidate failed: " .. res.body) + end + else + ngx.log(ngx.ERR, "validate ticket failed: res=", res, ", err=", err) + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end + return nil +end + +local function validate_with_cas(conf, ticket) + local user = validate(conf, ticket) + if user and set_store_and_cookie(ticket, user) then + local request_uri = ngx.var["cookie_" .. CAS_REQUEST_URI] + set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0") + ngx.log(ngx.INFO, "ticket: ", ticket, + ", cookie: ", ngx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user) + ngx.redirect(request_uri, ngx.HTTP_MOVED_TEMPORARILY) + else + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end +end + +local function logout(conf) + local session_id = get_session_id() + if session_id == nil then + return ngx.HTTP_UNAUTHORIZED + end + + ngx.log(ngx.INFO, "logout: ticket=", session_id, ", cookie=", ngx.var.http_cookie) + store:delete(session_id) + set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") + + ngx.redirect(conf.idp_uri .. "/logout") +end + +function _M.access(conf, ctx) + local method = ngx.req.get_method() + local uri = ngx.var.uri + + if method == "GET" and uri == conf.logout_uri then + return logout(conf) + elseif method == "POST" and uri == conf.cas_callback_uri then + ngx.req.read_body() + local data = ngx.req.get_body_data() + local ticket = data:match("(.*)") + if ticket == nil then + return 400, {message = "invalid logout request from IdP, no ticket"} + end + ngx.log(ngx.INFO, "Back-channel logout (SLO) from IdP: LogoutRequest: ", data) + local session_id = ticket + local user = store:get(session_id); + if user then + store:delete(session_id) + ngx.log(ngx.INFO, "SLO: user=", user, ", tocket=", ticket) + end + ngx.exit(200) + else + local session_id = get_session_id() + if session_id ~= nil then + return with_session_id(conf, session_id) + end + + local ticket = ngx.var.arg_ticket + if ticket ~= nil and uri == conf.cas_callback_uri then + validate_with_cas(conf, ticket) + else + first_access(conf) + end + end +end + +return _M diff --git a/ci/init-plugin-test-service.sh b/ci/init-plugin-test-service.sh index 5f468502304d..1f973ce36f47 100755 --- a/ci/init-plugin-test-service.sh +++ b/ci/init-plugin-test-service.sh @@ -41,3 +41,8 @@ docker exec -i rmqnamesrv /home/rocketmq/rocketmq-4.6.0/bin/mqadmin updateTopic # prepare vault kv engine docker exec -i vault sh -c "VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault secrets enable -path=kv -version=1 kv" + +# wait for keycloak ready +bash -c 'while true; do curl -s localhost:8080 &>/dev/null; ret=$?; [[ $ret -eq 0 ]] && break; sleep 3; done' +docker cp ci/kcadm_configure_cas.sh apisix_keycloak_new:/tmp/ +docker exec apisix_keycloak_new bash /tmp/kcadm_configure_cas.sh diff --git a/ci/kcadm_configure_cas.sh b/ci/kcadm_configure_cas.sh new file mode 100644 index 000000000000..f970a0e99154 --- /dev/null +++ b/ci/kcadm_configure_cas.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -ex + +export PATH=/opt/keycloak/bin:$PATH + +kcadm.sh config credentials --server http://localhost:28080 --realm master --user admin --password admin + +kcadm.sh create realms -s realm=test -s enabled=true + +kcadm.sh create users -r test -s username=test -s enabled=true +kcadm.sh set-password -r test --username test --new-password test + +clients=("cas1" "cas2") +rootUrls=("http://127.0.0.1:1984" "http://127.0.0.2:1984") + +for i in ${!clients[@]}; do + kcadm.sh create clients -r test -s clientId=${clients[$i]} -s enabled=true \ + -s protocol=cas -s frontchannelLogout=false -s rootUrl=${rootUrls[$i]} -s 'redirectUris=["/*"]' +done diff --git a/ci/pod/docker-compose.plugin.yml b/ci/pod/docker-compose.plugin.yml index 18d59a042433..4c0c4cb7e8e6 100644 --- a/ci/pod/docker-compose.plugin.yml +++ b/ci/pod/docker-compose.plugin.yml @@ -42,6 +42,28 @@ services: networks: apisix_net: + ## keycloak + # The keycloak official has two types of docker images: + # * legacy WildFly distribution + # * new Quarkus based distribution + # Here we choose new version, because it's mainstream and + # supports kcadm.sh to init the container for test. + # The original keycloak service `apisix_keycloak` is + # third-party personal customized image and for OIDC test only. + # We should unify both containers in future. + apisix_keycloak_new: + container_name: apisix_keycloak_new + image: quay.io/keycloak/keycloak:18.0.2 + # use host network because in CAS auth, + # keycloak needs to send back-channel POST to apisix. + network_mode: host + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + restart: unless-stopped + command: ["start-dev", "--http-port 8080"] + volumes: + - /opt/keycloak-protocol-cas-18.0.2.jar:/opt/keycloak/providers/keycloak-protocol-cas-18.0.2.jar ## kafka-cluster zookeeper-server1: diff --git a/conf/config-default.yaml b/conf/config-default.yaml index 80c8a3466dc0..3e4d06ecc51a 100755 --- a/conf/config-default.yaml +++ b/conf/config-default.yaml @@ -239,6 +239,7 @@ nginx_config: # config for render the template to generate n access-tokens: 1m ext-plugin: 1m tars: 1m + cas-auth: 10m # HashiCorp Vault storage backend for sensitive data retrieval. The config shows an example of what APISIX expects if you # wish to integrate Vault for secret (sensetive string, public private keys etc.) retrieval. APISIX communicates with Vault @@ -401,6 +402,7 @@ plugins: # plugin list (sorted by priority) - uri-blocker # priority: 2900 - request-validation # priority: 2800 - openid-connect # priority: 2599 + - cas-auth # priority: 2597 - authz-casbin # priority: 2560 - authz-casdoor # priority: 2559 - wolf-rbac # priority: 2555 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index baee9e8d0d32..478781cf0620 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -80,6 +80,7 @@ "plugins/authz-casdoor", "plugins/wolf-rbac", "plugins/openid-connect", + "plugins/cas-auth", "plugins/hmac-auth", "plugins/authz-casbin", "plugins/ldap-auth", diff --git a/t/APISIX.pm b/t/APISIX.pm index 28c2348a513c..496bad4e1107 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -546,6 +546,7 @@ _EOC_ lua_shared_dict tars 1m; lua_shared_dict xds-config 1m; lua_shared_dict xds-config-version 1m; + lua_shared_dict cas_sessions 10m; proxy_ssl_name \$upstream_host; proxy_ssl_server_name on; diff --git a/t/admin/plugins.t b/t/admin/plugins.t index 2bd1a4703b59..b13919138f08 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -78,6 +78,7 @@ csrf uri-blocker request-validation openid-connect +cas-auth authz-casbin authz-casdoor wolf-rbac diff --git a/t/lib/keycloak_cas.lua b/t/lib/keycloak_cas.lua new file mode 100644 index 000000000000..7e578014ce8f --- /dev/null +++ b/t/lib/keycloak_cas.lua @@ -0,0 +1,215 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +local http = require "resty.http" + +local _M = {} + +local default_opts = { + idp_uri = "http://127.0.0.1:8080/realms/test/protocol/cas", + cas_callback_uri = "/cas_callback", + logout_uri = "/logout", +} + +function _M.get_default_opts() + return default_opts +end + +-- Login keycloak and return the login original uri +function _M.login_keycloak(uri, username, password) + local httpc = http.new() + + local res, err = httpc:request_uri(uri, {method = "GET"}) + if not res then + return nil, err + elseif res.status ~= 302 then + return nil, "login was not redirected to keycloak." + else + local cookies = res.headers['Set-Cookie'] + local cookie_str = _M.concatenate_cookies(cookies) + + res, err = httpc:request_uri(res.headers['Location'], {method = "GET"}) + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 200 then + -- Unexpected response. + return nil, res.body + end + + -- From the returned form, extract the submit URI and parameters. + local uri, params = res.body:match('.*action="(.*)%?(.*)" method="post">') + + -- Substitute escaped ampersand in parameters. + params = params:gsub("&", "&") + + local auth_cookies = res.headers['Set-Cookie'] + + -- Concatenate cookies into one string as expected when sent in request header. + local auth_cookie_str = _M.concatenate_cookies(auth_cookies) + + -- Invoke the submit URI with parameters and cookies, adding username + -- and password in the body. + -- Note: Username and password are specific to the Keycloak Docker image used. + res, err = httpc:request_uri(uri .. "?" .. params, { + method = "POST", + body = "username=" .. username .. "&password=" .. password, + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded", + ["Cookie"] = auth_cookie_str + } + }) + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 302 then + -- Not a redirect which we expect. + return nil, "Login form submission did not return redirect to redirect URI." + end + + local keycloak_cookie_str = _M.concatenate_cookies(res.headers['Set-Cookie']) + + -- login callback + local redirect_uri = res.headers['Location'] + res, err = httpc:request_uri(redirect_uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 302 then + -- Not a redirect which we expect. + return nil, "login callback: " .. + "did not return redirect to original URI." + end + + cookies = res.headers['Set-Cookie'] + cookie_str = _M.concatenate_cookies(cookies) + + return res, nil, cookie_str, keycloak_cookie_str + end +end + +-- Login keycloak and return the login original uri +function _M.login_keycloak_for_second_sp(uri, keycloak_cookie_str) + local httpc = http.new() + + local res, err = httpc:request_uri(uri, {method = "GET"}) + if not res then + return nil, err + elseif res.status ~= 302 then + return nil, "login was not redirected to keycloak." + end + + local cookies = res.headers['Set-Cookie'] + local cookie_str = _M.concatenate_cookies(cookies) + + res, err = httpc:request_uri(res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = keycloak_cookie_str + } + }) + ngx.log(ngx.INFO, keycloak_cookie_str) + + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 302 then + -- Not a redirect which we expect. + return nil, res.body + end + + -- login callback + res, err = httpc:request_uri(res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 302 then + -- Not a redirect which we expect. + return nil, "login callback: " .. + "did not return redirect to original URI." + end + + cookies = res.headers['Set-Cookie'] + cookie_str = _M.concatenate_cookies(cookies) + + return res, nil, cookie_str +end + +function _M.logout_keycloak(uri, cookie_str, keycloak_cookie_str) + local httpc = http.new() + + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Cookie"] = cookie_str + } + }) + + if not res then + return nil, err + elseif res.status ~= 302 then + return nil, "logout was not redirected to keycloak." + else + -- keycloak logout + res, err = httpc:request_uri(res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = keycloak_cookie_str + } + }) + if not res then + -- No response, must be an error. + return nil, err + elseif res.status ~= 200 then + return nil, "Logout did not return 200." + end + + return res, nil + end +end + +-- Concatenate cookies into one string as expected when sent in request header. +function _M.concatenate_cookies(cookies) + local cookie_str = "" + if type(cookies) == 'string' then + cookie_str = cookies:match('([^;]*); .*') + else + -- Must be a table. + local len = #cookies + if len > 0 then + cookie_str = cookies[1]:match('([^;]*); .*') + for i = 2, len do + cookie_str = cookie_str .. "; " .. cookies[i]:match('([^;]*); .*') + end + end + end + + return cookie_str, nil +end + +return _M diff --git a/t/plugin/cas-auth.t b/t/plugin/cas-auth.t new file mode 100644 index 000000000000..d7629433759c --- /dev/null +++ b/t/plugin/cas-auth.t @@ -0,0 +1,227 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX 'no_plan'; + +log_level('warn'); +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: Add route for sp1 +--- config + location /t { + content_by_lua_block { + local kc = require("lib.keycloak_cas") + local core = require("apisix.core") + + local default_opts = kc.get_default_opts() + local opts = core.table.deepcopy(default_opts) + local t = require("lib.test_admin").test + + local code, body = t('/apisix/admin/routes/cas1', + ngx.HTTP_PUT, + [[{ + "methods": ["GET", "POST"], + "host" : "127.0.0.1", + "plugins": { + "cas-auth": ]] .. core.json.encode(opts) .. [[ + }, + "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 2: login and logout ok +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local kc = require "lib.keycloak_cas" + + local path = "/uri" + local uri = "http://127.0.0.1:" .. ngx.var.server_port + local username = "test" + local password = "test" + + local res, err, cas_cookie, keycloak_cookie = kc.login_keycloak(uri .. path, username, password) + if err or res.headers['Location'] ~= path then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + res, err = httpc:request_uri(uri .. res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = cas_cookie + } + }) + assert(res.status == 200) + ngx.say(res.body) + + res, err = kc.logout_keycloak(uri .. "/logout", cas_cookie, keycloak_cookie) + assert(res.status == 200) + } + } +--- response_body_like +uri: /uri +cookie: .* +host: 127.0.0.1:1984 +user-agent: .* +x-real-ip: 127.0.0.1 + + + +=== TEST 3: Add route for sp2 +--- config + location /t { + content_by_lua_block { + local kc = require("lib.keycloak_cas") + local core = require("apisix.core") + + local default_opts = kc.get_default_opts() + local opts = core.table.deepcopy(default_opts) + local t = require("lib.test_admin").test + + local code, body = t('/apisix/admin/routes/cas2', + ngx.HTTP_PUT, + [[{ + "methods": ["GET", "POST"], + "host" : "127.0.0.2", + "plugins": { + "cas-auth": ]] .. core.json.encode(opts) .. [[ + }, + "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 4: login sp1 and sp2, then do single logout +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local kc = require "lib.keycloak_cas" + + local path = "/uri" + + -- login to sp1 + local uri = "http://127.0.0.1:" .. ngx.var.server_port + local username = "test" + local password = "test" + + local res, err, cas_cookie, keycloak_cookie = kc.login_keycloak(uri .. path, username, password) + if err or res.headers['Location'] ~= path then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + res, err = httpc:request_uri(uri .. res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = cas_cookie + } + }) + assert(res.status == 200) + + -- login to sp2, which would skip login at keycloak side + local uri2 = "http://127.0.0.2:" .. ngx.var.server_port + + local res, err, cas_cookie2 = kc.login_keycloak_for_second_sp(uri2 .. path, keycloak_cookie) + if err or res.headers['Location'] ~= path then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + res, err = httpc:request_uri(uri2 .. res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = cas_cookie2 + } + }) + assert(res.status == 200) + + -- SLO (single logout) + res, err = kc.logout_keycloak(uri .. "/logout", cas_cookie, keycloak_cookie) + assert(res.status == 200) + + -- login to sp2, which would do normal login process at keycloak side + local res, err, cas_cookie2, keycloak_cookie = kc.login_keycloak(uri2 .. path, username, password) + if err or res.headers['Location'] ~= path then + ngx.log(ngx.ERR, err) + ngx.exit(500) + end + res, err = httpc:request_uri(uri .. res.headers['Location'], { + method = "GET", + headers = { + ["Cookie"] = cas_cookie2 + } + }) + assert(res.status == 200) + + -- logout sp2 + res, err = kc.logout_keycloak(uri2 .. "/logout", cas_cookie2, keycloak_cookie) + assert(res.status == 200) + } + } From 11073404236d8c9246b02bb67f23247c82967252 Mon Sep 17 00:00:00 2001 From: kingluo Date: Fri, 16 Sep 2022 16:34:32 +0800 Subject: [PATCH 02/12] fix indent --- apisix/plugins/cas-auth.lua | 210 ++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 70b93f2018d3..ec97fba69807 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -52,156 +52,156 @@ function _M.check_schema(conf) end local function to_table(v) - if v == nil then - return {} - elseif type(v) == "table" then - return v - else - return {v} - end + if v == nil then + return {} + elseif type(v) == "table" then + return v + else + return {v} + end end local function set_cookie(cookie_str) - local h = to_table(ngx.header['Set-Cookie']) - table.insert(h, cookie_str) - ngx.header['Set-Cookie'] = h + local h = to_table(ngx.header['Set-Cookie']) + table.insert(h, cookie_str) + ngx.header['Set-Cookie'] = h end local function uri_without_ticket(conf) - return ngx.var.scheme .. "://" .. ngx.var.host .. ":" .. + return ngx.var.scheme .. "://" .. ngx.var.host .. ":" .. ngx.var.server_port .. conf.cas_callback_uri end local function get_session_id() - return ngx.var["cookie_" .. COOKIE_NAME] + return ngx.var["cookie_" .. COOKIE_NAME] end local function set_our_cookie(name, val) - set_cookie(name .. "=" .. val .. COOKIE_PARAMS) + set_cookie(name .. "=" .. val .. COOKIE_PARAMS) end local function first_access(conf) - local login_uri = conf.idp_uri .. "/login?" .. + local login_uri = conf.idp_uri .. "/login?" .. ngx.encode_args({ service = uri_without_ticket(conf) }) - ngx.log(ngx.INFO, "first access: ", login_uri, + ngx.log(ngx.INFO, "first access: ", login_uri, ", cookie: ", ngx.var.http_cookie, ", request_uri: ", ngx.var.request_uri) - set_our_cookie(CAS_REQUEST_URI, ngx.var.request_uri) - ngx.redirect(login_uri, ngx.HTTP_MOVED_TEMPORARILY) + set_our_cookie(CAS_REQUEST_URI, ngx.var.request_uri) + ngx.redirect(login_uri, ngx.HTTP_MOVED_TEMPORARILY) end local function with_session_id(conf, session_id) - -- does the cookie exist in our store? - local user = store:get(session_id); - ngx.log(ngx.INFO, "ticket=", session_id, ", user=", user) - if user == nil then - set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") - first_access(conf) - else - -- refresh the TTL - store:set(session_id, user, SESSION_LIFETIME) - end + -- does the cookie exist in our store? + local user = store:get(session_id); + ngx.log(ngx.INFO, "ticket=", session_id, ", user=", user) + if user == nil then + set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") + first_access(conf) + else + -- refresh the TTL + store:set(session_id, user, SESSION_LIFETIME) + end end local function set_store_and_cookie(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 - ngx.log(ngx.INFO, "CAS cookie store is out of memory") - end - set_our_cookie(COOKIE_NAME, session_id) - else - if err == "no memory" then - ngx.log(ngx.EMERG, "CAS cookie store is out of memory") - elseif err == "exists" then - ngx.log(ngx.ERR, "Same CAS ticket validated twice, this should never happen!") - end - end - return success + -- place cookie into cookie store + local success, err, forcible = store:add(session_id, user, SESSION_LIFETIME) + if success then + if forcible then + ngx.log(ngx.INFO, "CAS cookie store is out of memory") + end + set_our_cookie(COOKIE_NAME, session_id) + else + if err == "no memory" then + ngx.log(ngx.EMERG, "CAS cookie store is out of memory") + elseif err == "exists" then + ngx.log(ngx.ERR, "Same CAS ticket validated twice, this should never happen!") + end + end + return success end local function validate(conf, ticket) - -- send a request to CAS to validate the ticket - local httpc = http.new() - local res, err = httpc:request_uri(conf.idp_uri .. + -- send a request to CAS to validate the ticket + local httpc = http.new() + local res, err = httpc:request_uri(conf.idp_uri .. "/serviceValidate", { query = { ticket = ticket, service = uri_without_ticket(conf) } }) - if res and res.status == ngx.HTTP_OK and res.body ~= nil then - if string.find(res.body, "") then - local m = ngx.re.match(res.body, "(.*?)"); - if m then - return m[1] - end - else - ngx.log(ngx.INFO, "CAS serviceValidate failed: " .. res.body) - end - else - ngx.log(ngx.ERR, "validate ticket failed: res=", res, ", err=", err) - ngx.exit(ngx.HTTP_UNAUTHORIZED) - end - return nil + if res and res.status == ngx.HTTP_OK and res.body ~= nil then + if string.find(res.body, "") then + local m = ngx.re.match(res.body, "(.*?)"); + if m then + return m[1] + end + else + ngx.log(ngx.INFO, "CAS serviceValidate failed: " .. res.body) + end + else + ngx.log(ngx.ERR, "validate ticket failed: res=", res, ", err=", err) + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end + return nil end local function validate_with_cas(conf, ticket) - local user = validate(conf, ticket) - if user and set_store_and_cookie(ticket, user) then - local request_uri = ngx.var["cookie_" .. CAS_REQUEST_URI] - set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0") - ngx.log(ngx.INFO, "ticket: ", ticket, + local user = validate(conf, ticket) + if user and set_store_and_cookie(ticket, user) then + local request_uri = ngx.var["cookie_" .. CAS_REQUEST_URI] + set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0") + ngx.log(ngx.INFO, "ticket: ", ticket, ", cookie: ", ngx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user) - ngx.redirect(request_uri, ngx.HTTP_MOVED_TEMPORARILY) - else - ngx.exit(ngx.HTTP_UNAUTHORIZED) - end + ngx.redirect(request_uri, ngx.HTTP_MOVED_TEMPORARILY) + else + ngx.exit(ngx.HTTP_UNAUTHORIZED) + end end local function logout(conf) - local session_id = get_session_id() - if session_id == nil then - return ngx.HTTP_UNAUTHORIZED - end + local session_id = get_session_id() + if session_id == nil then + return ngx.HTTP_UNAUTHORIZED + end - ngx.log(ngx.INFO, "logout: ticket=", session_id, ", cookie=", ngx.var.http_cookie) - store:delete(session_id) - set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") + ngx.log(ngx.INFO, "logout: ticket=", session_id, ", cookie=", ngx.var.http_cookie) + store:delete(session_id) + set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") - ngx.redirect(conf.idp_uri .. "/logout") + ngx.redirect(conf.idp_uri .. "/logout") end function _M.access(conf, ctx) - local method = ngx.req.get_method() - local uri = ngx.var.uri - - if method == "GET" and uri == conf.logout_uri then - return logout(conf) - elseif method == "POST" and uri == conf.cas_callback_uri then - ngx.req.read_body() - local data = ngx.req.get_body_data() - local ticket = data:match("(.*)") - if ticket == nil then - return 400, {message = "invalid logout request from IdP, no ticket"} - end - ngx.log(ngx.INFO, "Back-channel logout (SLO) from IdP: LogoutRequest: ", data) - local session_id = ticket - local user = store:get(session_id); + local method = ngx.req.get_method() + local uri = ngx.var.uri + + if method == "GET" and uri == conf.logout_uri then + return logout(conf) + elseif method == "POST" and uri == conf.cas_callback_uri then + ngx.req.read_body() + local data = ngx.req.get_body_data() + local ticket = data:match("(.*)") + if ticket == nil then + return 400, {message = "invalid logout request from IdP, no ticket"} + end + ngx.log(ngx.INFO, "Back-channel logout (SLO) from IdP: LogoutRequest: ", data) + local session_id = ticket + local user = store:get(session_id); if user then store:delete(session_id) ngx.log(ngx.INFO, "SLO: user=", user, ", tocket=", ticket) end - ngx.exit(200) - else - local session_id = get_session_id() - if session_id ~= nil then - return with_session_id(conf, session_id) - end - - local ticket = ngx.var.arg_ticket - if ticket ~= nil and uri == conf.cas_callback_uri then - validate_with_cas(conf, ticket) - else - first_access(conf) - end - end + ngx.exit(200) + else + local session_id = get_session_id() + if session_id ~= nil then + return with_session_id(conf, session_id) + end + + local ticket = ngx.var.arg_ticket + if ticket ~= nil and uri == conf.cas_callback_uri then + validate_with_cas(conf, ticket) + else + first_access(conf) + end + end end return _M From b48a4952e7dadbea8189bfa15d304ba4adc380b4 Mon Sep 17 00:00:00 2001 From: kingluo Date: Fri, 16 Sep 2022 16:48:23 +0800 Subject: [PATCH 03/12] fix global var --- apisix/plugins/cas-auth.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index ec97fba69807..5fd1f7628981 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -17,6 +17,9 @@ local core = require("apisix.core") local http = require("resty.http") local ngx = ngx +local type = type +local table = table +local string = string local CAS_REQUEST_URI = "CAS_REQUEST_URI" local COOKIE_NAME = "CAS_SESSION" From 89dd0ea2bc92993d0d3ef1c7960c9d11f463543d Mon Sep 17 00:00:00 2001 From: kingluo Date: Mon, 19 Sep 2022 10:25:43 +0800 Subject: [PATCH 04/12] add cas-auth.md and some bugfix --- apisix/plugins/cas-auth.lua | 6 +- ci/kcadm_configure_cas.sh | 2 +- docs/en/latest/plugins/cas-auth.md | 107 +++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 docs/en/latest/plugins/cas-auth.md diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 5fd1f7628981..ce12729428a7 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -140,7 +140,6 @@ local function validate(conf, ticket) end else ngx.log(ngx.ERR, "validate ticket failed: res=", res, ", err=", err) - ngx.exit(ngx.HTTP_UNAUTHORIZED) end return nil end @@ -154,7 +153,7 @@ local function validate_with_cas(conf, ticket) ", cookie: ", ngx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user) ngx.redirect(request_uri, ngx.HTTP_MOVED_TEMPORARILY) else - ngx.exit(ngx.HTTP_UNAUTHORIZED) + return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"} end end @@ -191,7 +190,6 @@ function _M.access(conf, ctx) store:delete(session_id) ngx.log(ngx.INFO, "SLO: user=", user, ", tocket=", ticket) end - ngx.exit(200) else local session_id = get_session_id() if session_id ~= nil then @@ -200,7 +198,7 @@ function _M.access(conf, ctx) local ticket = ngx.var.arg_ticket if ticket ~= nil and uri == conf.cas_callback_uri then - validate_with_cas(conf, ticket) + return validate_with_cas(conf, ticket) else first_access(conf) end diff --git a/ci/kcadm_configure_cas.sh b/ci/kcadm_configure_cas.sh index f970a0e99154..3486667decbb 100644 --- a/ci/kcadm_configure_cas.sh +++ b/ci/kcadm_configure_cas.sh @@ -21,7 +21,7 @@ set -ex export PATH=/opt/keycloak/bin:$PATH -kcadm.sh config credentials --server http://localhost:28080 --realm master --user admin --password admin +kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password admin kcadm.sh create realms -s realm=test -s enabled=true diff --git a/docs/en/latest/plugins/cas-auth.md b/docs/en/latest/plugins/cas-auth.md new file mode 100644 index 000000000000..434a86563911 --- /dev/null +++ b/docs/en/latest/plugins/cas-auth.md @@ -0,0 +1,107 @@ +--- +title: cas-auth +keywords: + - APISIX + - Plugin + - CAS AUTH + - cas-auth +description: This document contains information about the Apache APISIX cas-auth Plugin. +--- + + + +## Description + +The `cas-auth` Plugin can be used to access CAS (Central Authentication Service 2.0) IdP (Identity Provider) +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. | + +## Enabling the Plugin + +You can enable the Plugin on a specific Route as shown below: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/cas1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "methods": ["GET", "POST"], + "host" : "127.0.0.1", + "uri": "/anything/*", + "plugins": { + "cas-auth": { + "idp_uri": "http://127.0.0.1:8080/realms/test/protocol/cas", + "cas_callback_uri": "/anything/cas_callback", + "logout_uri": "/anything/logout" + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org": 1 + } + } +}' + +``` + +## Configuration description + +Once you have enabled the Plugin, a new user visiting this Route would first be processed by the `cas-auth` Plugin. +If no login session exists, the user would be redirected to the login page of `idp_uri`. + +After successfully logging in from IdP, IdP will redirect this user to the `cas_callback_uri` with +GET parameters CAS ticket specified. If the ticket gets verified, the login session would be created. + +This process is only done once and subsequent requests are left uninterrupted. +Once this is done, the user is redirected to the original URL they wanted to visit. + +Later, the user could visit `logout_uri` to start logout process. The user would be redirected to `idp_uri` to do logout. + +Note that, `cas_callback_uri` and `logout_uri` should be +either full qualified address (e.g. `http://127.0.0.1:9080/anything/logout`), +or path only (e.g. `/anything/logout`), but it is recommended to be path only to keep consistent. + +These uris need to be captured by the route where the current APISIX is located. +For example, if the `uri` of the current route is `/api/v1/*`, `cas_callback_uri` can be filled in as `/api/v1/cas_callback`. + +## Disable Plugin + +To disable the `cas-auth` Plugin, you can delete the corresponding JSON configuration from the Plugin configuration. APISIX will automatically reload and you do not have to restart for this to take effect. + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/cas1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "methods": ["GET", "POST"], + "uri": "/anything/*", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org:80": 1 + } + } +}' +``` From 3d4dab41728a0dfab6f66e29f6e523d80b01c609 Mon Sep 17 00:00:00 2001 From: kingluo Date: Mon, 19 Sep 2022 15:49:47 +0800 Subject: [PATCH 05/12] fix PR --- apisix/plugins/cas-auth.lua | 103 ++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index ce12729428a7..2e5e529caed5 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -54,51 +54,36 @@ function _M.check_schema(conf) return core.schema.check(schema, conf) end -local function to_table(v) - if v == nil then - return {} - elseif type(v) == "table" then - return v - else - return {v} - end -end - -local function set_cookie(cookie_str) - local h = to_table(ngx.header['Set-Cookie']) - table.insert(h, cookie_str) - ngx.header['Set-Cookie'] = h -end - -local function uri_without_ticket(conf) - return ngx.var.scheme .. "://" .. ngx.var.host .. ":" .. - ngx.var.server_port .. conf.cas_callback_uri +local function uri_without_ticket(conf, ctx) + return ctx.var.scheme .. "://" .. ctx.var.host .. ":" .. + ctx.var.server_port .. conf.cas_callback_uri end -local function get_session_id() - return ngx.var["cookie_" .. COOKIE_NAME] +local function get_session_id(ctx) + return ctx.var["cookie_" .. COOKIE_NAME] end local function set_our_cookie(name, val) - set_cookie(name .. "=" .. val .. COOKIE_PARAMS) + core.response.add_header("Set-Cookie", name .. "=" .. val .. COOKIE_PARAMS) end -local function first_access(conf) +local function first_access(conf, ctx) local login_uri = conf.idp_uri .. "/login?" .. - ngx.encode_args({ service = uri_without_ticket(conf) }) - ngx.log(ngx.INFO, "first access: ", login_uri, - ", cookie: ", ngx.var.http_cookie, ", request_uri: ", ngx.var.request_uri) - set_our_cookie(CAS_REQUEST_URI, ngx.var.request_uri) - ngx.redirect(login_uri, ngx.HTTP_MOVED_TEMPORARILY) + 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.response.set_header("Location", login_uri) + return ngx.HTTP_MOVED_TEMPORARILY end -local function with_session_id(conf, session_id) +local function with_session_id(conf, ctx, session_id) -- does the cookie exist in our store? local user = store:get(session_id); - ngx.log(ngx.INFO, "ticket=", session_id, ", user=", user) + core.log.info("ticket=", session_id, ", user=", user) if user == nil then set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") - first_access(conf) + return first_access(conf, ctx) else -- refresh the TTL store:set(session_id, user, SESSION_LIFETIME) @@ -110,72 +95,74 @@ local function set_store_and_cookie(session_id, user) local success, err, forcible = store:add(session_id, user, SESSION_LIFETIME) if success then if forcible then - ngx.log(ngx.INFO, "CAS cookie store is out of memory") + core.log.info("CAS cookie store is out of memory") end set_our_cookie(COOKIE_NAME, session_id) else if err == "no memory" then - ngx.log(ngx.EMERG, "CAS cookie store is out of memory") + core.log.emerg("CAS cookie store is out of memory") elseif err == "exists" then - ngx.log(ngx.ERR, "Same CAS ticket validated twice, this should never happen!") + core.log.error("Same CAS ticket validated twice, this should never happen!") end end return success end -local function validate(conf, ticket) +local function validate(conf, ctx, ticket) -- send a request to CAS to validate the ticket local httpc = http.new() local res, err = httpc:request_uri(conf.idp_uri .. - "/serviceValidate", { query = { ticket = ticket, service = uri_without_ticket(conf) } }) + "/serviceValidate", { query = { ticket = ticket, service = uri_without_ticket(conf, ctx) } }) if res and res.status == ngx.HTTP_OK and res.body ~= nil then - if string.find(res.body, "") then + if core.string.find(res.body, "") then local m = ngx.re.match(res.body, "(.*?)"); if m then return m[1] end else - ngx.log(ngx.INFO, "CAS serviceValidate failed: " .. res.body) + core.log.info("CAS serviceValidate failed: ", res.body) end else - ngx.log(ngx.ERR, "validate ticket failed: res=", res, ", err=", err) + core.log.error("validate ticket failed: res=", res, ", err=", err) end return nil end -local function validate_with_cas(conf, ticket) - local user = validate(conf, ticket) +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 = ngx.var["cookie_" .. CAS_REQUEST_URI] + local request_uri = ctx.var["cookie_" .. CAS_REQUEST_URI] set_our_cookie(CAS_REQUEST_URI, "deleted; Max-Age=0") - ngx.log(ngx.INFO, "ticket: ", ticket, - ", cookie: ", ngx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user) - ngx.redirect(request_uri, ngx.HTTP_MOVED_TEMPORARILY) + core.log.info("ticket: ", ticket, + ", cookie: ", ctx.var.http_cookie, ", request_uri: ", request_uri, ", user=", user) + core.response.set_header("Location", request_uri) + return ngx.HTTP_MOVED_TEMPORARILY else return ngx.HTTP_UNAUTHORIZED, {message = "invalid ticket"} end end -local function logout(conf) - local session_id = get_session_id() +local function logout(conf, ctx) + local session_id = get_session_id(ctx) if session_id == nil then return ngx.HTTP_UNAUTHORIZED end - ngx.log(ngx.INFO, "logout: ticket=", session_id, ", cookie=", ngx.var.http_cookie) + core.log.info("logout: ticket=", session_id, ", cookie=", ctx.var.http_cookie) store:delete(session_id) set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0") - ngx.redirect(conf.idp_uri .. "/logout") + core.response.set_header("Location", conf.idp_uri .. "/logout") + return ngx.HTTP_MOVED_TEMPORARILY end function _M.access(conf, ctx) local method = ngx.req.get_method() - local uri = ngx.var.uri + local uri = ctx.var.uri if method == "GET" and uri == conf.logout_uri then - return logout(conf) + return logout(conf, ctx) elseif method == "POST" and uri == conf.cas_callback_uri then ngx.req.read_body() local data = ngx.req.get_body_data() @@ -183,24 +170,24 @@ function _M.access(conf, ctx) if ticket == nil then return 400, {message = "invalid logout request from IdP, no ticket"} end - ngx.log(ngx.INFO, "Back-channel logout (SLO) from IdP: LogoutRequest: ", data) + core.log.info("Back-channel logout (SLO) from IdP: LogoutRequest: ", data) local session_id = ticket local user = store:get(session_id); if user then store:delete(session_id) - ngx.log(ngx.INFO, "SLO: user=", user, ", tocket=", ticket) + core.log.info("SLO: user=", user, ", tocket=", ticket) end else - local session_id = get_session_id() + local session_id = get_session_id(ctx) if session_id ~= nil then - return with_session_id(conf, session_id) + return with_session_id(conf, ctx, session_id) end - local ticket = ngx.var.arg_ticket + local ticket = ctx.var.arg_ticket if ticket ~= nil and uri == conf.cas_callback_uri then - return validate_with_cas(conf, ticket) + return validate_with_cas(conf, ctx, ticket) else - first_access(conf) + return first_access(conf, ctx) end end end From 88ab81e46732521c6334eb0a14d89539f9677361 Mon Sep 17 00:00:00 2001 From: kingluo Date: Mon, 19 Sep 2022 15:55:38 +0800 Subject: [PATCH 06/12] fix code lint --- apisix/plugins/cas-auth.lua | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 2e5e529caed5..a25c40c0f9b1 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -17,9 +17,6 @@ local core = require("apisix.core") local http = require("resty.http") local ngx = ngx -local type = type -local table = table -local string = string local CAS_REQUEST_URI = "CAS_REQUEST_URI" local COOKIE_NAME = "CAS_SESSION" @@ -112,7 +109,8 @@ local function validate(conf, ctx, ticket) -- send a request to CAS to validate the ticket local httpc = http.new() local res, err = httpc:request_uri(conf.idp_uri .. - "/serviceValidate", { query = { ticket = ticket, service = uri_without_ticket(conf, ctx) } }) + "/serviceValidate", + { query = { ticket = ticket, service = uri_without_ticket(conf, ctx) } }) if res and res.status == ngx.HTTP_OK and res.body ~= nil then if core.string.find(res.body, "") then From 7a7bdbe39fb360561020d476d45208b6500a164c Mon Sep 17 00:00:00 2001 From: kingluo Date: Tue, 20 Sep 2022 12:24:24 +0800 Subject: [PATCH 07/12] fix PR --- apisix/plugins/cas-auth.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index a25c40c0f9b1..4fca24902220 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -17,6 +17,7 @@ local core = require("apisix.core") local http = require("resty.http") local ngx = ngx +local ngx_re_match = ngx.re.match local CAS_REQUEST_URI = "CAS_REQUEST_URI" local COOKIE_NAME = "CAS_SESSION" @@ -100,6 +101,8 @@ local function set_store_and_cookie(session_id, user) core.log.emerg("CAS cookie store is out of memory") elseif err == "exists" then core.log.error("Same CAS ticket validated twice, this should never happen!") + else + core.log.error("CAS cookie store: ", err) end end return success @@ -114,7 +117,7 @@ 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, "") then - local m = ngx.re.match(res.body, "(.*?)"); + local m = ngx_re_match(res.body, "(.*?)"); if m then return m[1] end @@ -156,14 +159,15 @@ local function logout(conf, ctx) end function _M.access(conf, ctx) - local method = ngx.req.get_method() + local method = core.request.get_method() local uri = ctx.var.uri if method == "GET" and uri == conf.logout_uri then return logout(conf, ctx) - elseif method == "POST" and uri == conf.cas_callback_uri then - ngx.req.read_body() - local data = ngx.req.get_body_data() + end + + if method == "POST" and uri == conf.cas_callback_uri then + local data = core.request.get_body() local ticket = data:match("(.*)") if ticket == nil then return 400, {message = "invalid logout request from IdP, no ticket"} From 152d2e06868e8cb34eca2338fc1420a0ae01ff0a Mon Sep 17 00:00:00 2001 From: kingluo Date: Tue, 20 Sep 2022 15:02:07 +0800 Subject: [PATCH 08/12] use jo in ngx.re.match --- apisix/plugins/cas-auth.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 4fca24902220..b3f6e7a6deff 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -117,7 +117,7 @@ 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, "") then - local m = ngx_re_match(res.body, "(.*?)"); + local m = ngx_re_match(res.body, "(.*?)", "jo"); if m then return m[1] end From 17dfca636dbad744ca9e95f222fdd339fde55881 Mon Sep 17 00:00:00 2001 From: kingluo Date: Tue, 20 Sep 2022 17:26:50 +0800 Subject: [PATCH 09/12] fix PR --- .github/workflows/build.yml | 7 +++---- .github/workflows/centos7-ci.yml | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d33e9a0e957d..9c251732868f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,10 +97,6 @@ jobs: rm -rf $(ls -1 --ignore=*.tgz --ignore=ci --ignore=t --ignore=utils --ignore=.github) tar zxvf ${{ steps.branch_env.outputs.fullname }} - - name: download keycloak cas provider - run: | - sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar - - name: Start CI env (FIRST_TEST) if: steps.test_env.outputs.type == 'first' run: | @@ -110,6 +106,9 @@ jobs: - name: Start CI env (PLUGIN_TEST) if: steps.test_env.outputs.type == 'plugin' run: | + # download keycloak cas provider + sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar + sh ci/pod/openfunction/build-function-image.sh make ci-env-up project_compose_ci=ci/pod/docker-compose.${{ steps.test_env.outputs.type }}.yml sudo ./ci/init-${{ steps.test_env.outputs.type }}-test-service.sh diff --git a/.github/workflows/centos7-ci.yml b/.github/workflows/centos7-ci.yml index e0aa25cc709c..2da72dc6b190 100644 --- a/.github/workflows/centos7-ci.yml +++ b/.github/workflows/centos7-ci.yml @@ -93,10 +93,6 @@ jobs: docker run -itd -v /home/runner/work/apisix/apisix:/apisix --env TEST_FILE_SUB_DIR="$TEST_FILE_SUB_DIR" --name centos7Instance --net="host" --dns 8.8.8.8 --dns-search apache.org docker.io/centos:7 /bin/bash # docker exec centos7Instance bash -c "cp -r /tmp/apisix ./" - - name: download keycloak cas provider - run: | - sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar - - name: Start CI env (FIRST_TEST) if: steps.test_env.outputs.type == 'first' run: | @@ -105,6 +101,9 @@ jobs: - name: Start CI env (PLUGIN_TEST) if: steps.test_env.outputs.type == 'plugin' run: | + # download keycloak cas provider + sudo wget https://github.com/jacekkow/keycloak-protocol-cas/releases/download/18.0.2/keycloak-protocol-cas-18.0.2.jar -O /opt/keycloak-protocol-cas-18.0.2.jar + sh ci/pod/openfunction/build-function-image.sh make ci-env-up project_compose_ci=ci/pod/docker-compose.${{ steps.test_env.outputs.type }}.yml ./ci/init-${{ steps.test_env.outputs.type }}-test-service.sh From 6a88fa58664e7ab33117754ca2f295838c0a3b35 Mon Sep 17 00:00:00 2001 From: kingluo Date: Wed, 21 Sep 2022 16:22:06 +0800 Subject: [PATCH 10/12] fix PR --- apisix/plugins/cas-auth.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index b3f6e7a6deff..4ba068a58416 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -125,7 +125,13 @@ local function validate(conf, ctx, ticket) core.log.info("CAS serviceValidate failed: ", res.body) end else - core.log.error("validate ticket failed: res=", res, ", err=", err) + local status + local has_body = false + if res then + status = res.status + has_body = res.body ~= nil + end + core.log.error("validate ticket failed: status=", status, ", has_body=", has_body, ", err=", err) end return nil end From 5e3d3293f74ac14c92364fdbd628c88da8282c2b Mon Sep 17 00:00:00 2001 From: kingluo Date: Wed, 21 Sep 2022 16:34:37 +0800 Subject: [PATCH 11/12] fix lint --- apisix/plugins/cas-auth.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 4ba068a58416..4e7a70351e33 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -131,7 +131,8 @@ local function validate(conf, ctx, ticket) status = res.status has_body = res.body ~= nil end - core.log.error("validate ticket failed: status=", status, ", has_body=", has_body, ", err=", err) + core.log.error("validate ticket failed: status=", status, + ", has_body=", has_body, ", err=", err) end return nil end From 8933873efd9fc1ce0e4898036781978e61b35db1 Mon Sep 17 00:00:00 2001 From: kingluo Date: Thu, 22 Sep 2022 15:12:23 +0800 Subject: [PATCH 12/12] fix PR --- apisix/plugins/cas-auth.lua | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apisix/plugins/cas-auth.lua b/apisix/plugins/cas-auth.lua index 4e7a70351e33..2a3e5049c446 100644 --- a/apisix/plugins/cas-auth.lua +++ b/apisix/plugins/cas-auth.lua @@ -125,14 +125,8 @@ local function validate(conf, ctx, ticket) core.log.info("CAS serviceValidate failed: ", res.body) end else - local status - local has_body = false - if res then - status = res.status - has_body = res.body ~= nil - end - core.log.error("validate ticket failed: status=", status, - ", has_body=", has_body, ", err=", err) + core.log.error("validate ticket failed: status=", (res and res.status), + ", has_body=", (res and res.body ~= nil or false), ", err=", err) end return nil end @@ -177,7 +171,8 @@ function _M.access(conf, ctx) local data = core.request.get_body() local ticket = data:match("(.*)") if ticket == nil then - return 400, {message = "invalid logout request from IdP, no ticket"} + 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) local session_id = ticket