Skip to content

Commit

Permalink
feat: add cas-auth plugin (#7932)
Browse files Browse the repository at this point in the history
  • Loading branch information
kingluo authored Sep 22, 2022
1 parent 79793b1 commit 987f7be
Show file tree
Hide file tree
Showing 14 changed files with 827 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/centos7-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,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
Expand Down
4 changes: 4 additions & 0 deletions apisix/cli/ngx_tpl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions apisix/plugins/cas-auth.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
--
---- 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 ngx_re_match = ngx.re.match

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 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(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)
end

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.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)
if user == nil then
set_our_cookie(COOKIE_NAME, "deleted; Max-Age=0")
return first_access(conf, ctx)
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
core.log.info("CAS cookie store is out of memory")
end
set_our_cookie(COOKIE_NAME, session_id)
else
if err == "no memory" then
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
end

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) } })

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");
if m then
return m[1]
end
else
core.log.info("CAS serviceValidate failed: ", res.body)
end
else
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

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)
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, ctx)
local session_id = get_session_id(ctx)
if session_id == nil then
return ngx.HTTP_UNAUTHORIZED
end

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")

core.response.set_header("Location", conf.idp_uri .. "/logout")
return ngx.HTTP_MOVED_TEMPORARILY
end

function _M.access(conf, ctx)
local method = core.request.get_method()
local uri = ctx.var.uri

if method == "GET" and uri == conf.logout_uri then
return logout(conf, ctx)
end

if method == "POST" and uri == conf.cas_callback_uri then
local data = core.request.get_body()
local ticket = data:match("<samlp:SessionIndex>(.*)</samlp:SessionIndex>")
if ticket == nil then
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
local user = store:get(session_id);
if user then
store:delete(session_id)
core.log.info("SLO: user=", user, ", tocket=", ticket)
end
else
local session_id = get_session_id(ctx)
if session_id ~= nil then
return with_session_id(conf, ctx, session_id)
end

local ticket = ctx.var.arg_ticket
if ticket ~= nil and uri == conf.cas_callback_uri then
return validate_with_cas(conf, ctx, ticket)
else
return first_access(conf, ctx)
end
end
end

return _M
5 changes: 5 additions & 0 deletions ci/init-plugin-test-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions ci/kcadm_configure_cas.sh
Original file line number Diff line number Diff line change
@@ -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:8080 --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
22 changes: 22 additions & 0 deletions ci/pod/docker-compose.plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions conf/config-default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/en/latest/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 987f7be

Please sign in to comment.