Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cas-auth plugin #7932

Merged
merged 12 commits into from
Sep 22, 2022
Merged
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
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