This is an automated email from the ASF dual-hosted git repository. membphis pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-apisix.git
The following commit(s) were added to refs/heads/master by this push: new cd98a2b feature: support authorization Plugin for Keycloak Identity Server (#1701) cd98a2b is described below commit cd98a2bec8bee2a2cea5b0bbbb892f25e7394cf0 Author: Nirojan Selvanathan <sshn...@gmail.com> AuthorDate: Tue Jun 16 07:51:36 2020 +0200 feature: support authorization Plugin for Keycloak Identity Server (#1701) --- .travis/linux_openresty_runner.sh | 2 + .travis/linux_tengine_runner.sh | 2 + apisix/plugins/authz-keycloak.lua | 165 +++++++++++++++++ conf/config.yaml | 1 + doc/images/plugin/authz-keycloak.png | Bin 0 -> 51957 bytes doc/plugins/authz-keycloak.md | 135 ++++++++++++++ t/admin/plugins.t | 2 +- t/debug/debug-mode.t | 1 + t/plugin/authz-keycloak.t | 349 +++++++++++++++++++++++++++++++++++ 9 files changed, 656 insertions(+), 1 deletion(-) diff --git a/.travis/linux_openresty_runner.sh b/.travis/linux_openresty_runner.sh index ea5dce9..d569983 100755 --- a/.travis/linux_openresty_runner.sh +++ b/.travis/linux_openresty_runner.sh @@ -38,6 +38,8 @@ before_install() { docker pull redis:3.0-alpine docker run --rm -itd -p 6379:6379 --name apisix_redis redis:3.0-alpine docker run --rm -itd -e HTTP_PORT=8888 -e HTTPS_PORT=9999 -p 8888:8888 -p 9999:9999 mendhak/http-https-echo + # Runs Keycloak version 10.0.2 with inbuilt policies for unit tests + docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 sshniro/keycloak-apisix # spin up kafka cluster for tests (1 zookeper and 1 kafka instance) docker pull bitnami/zookeeper:3.6.0 docker pull bitnami/kafka:latest diff --git a/.travis/linux_tengine_runner.sh b/.travis/linux_tengine_runner.sh index 74aa909..472e86f 100755 --- a/.travis/linux_tengine_runner.sh +++ b/.travis/linux_tengine_runner.sh @@ -39,6 +39,8 @@ before_install() { docker pull redis:3.0-alpine docker run --rm -itd -p 6379:6379 --name apisix_redis redis:3.0-alpine docker run --rm -itd -e HTTP_PORT=8888 -e HTTPS_PORT=9999 -p 8888:8888 -p 9999:9999 mendhak/http-https-echo + # Runs Keycloak version 10.0.2 with inbuilt policies for unit tests + docker run --rm -itd -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 sshniro/keycloak-apisix # spin up kafka cluster for tests (1 zookeper and 1 kafka instance) docker pull bitnami/zookeeper:3.6.0 docker pull bitnami/kafka:latest diff --git a/apisix/plugins/authz-keycloak.lua b/apisix/plugins/authz-keycloak.lua new file mode 100644 index 0000000..2704f4e --- /dev/null +++ b/apisix/plugins/authz-keycloak.lua @@ -0,0 +1,165 @@ +-- +-- 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 sub_str = string.sub +local url = require "net.url" +local tostring = tostring +local ngx = ngx +local plugin_name = "authz-keycloak" + + +local schema = { + type = "object", + properties = { + token_endpoint = {type = "string", minLength = 1, maxLength = 4096}, + permissions = { + type = "array", + items = { + type = "string", + minLength = 1, maxLength = 100 + }, + uniqueItems = true + }, + grant_type = { + type = "string", + default="urn:ietf:params:oauth:grant-type:uma-ticket", + enum = {"urn:ietf:params:oauth:grant-type:uma-ticket"}, + minLength = 1, maxLength = 100 + }, + audience = {type = "string", minLength = 1, maxLength = 100}, + timeout = {type = "integer", minimum = 1000, default = 3000}, + policy_enforcement_mode = { + type = "string", + enum = {"ENFORCING", "PERMISSIVE"}, + default = "ENFORCING" + }, + keepalive = {type = "boolean", default = true}, + keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, + keepalive_pool = {type = "integer", minimum = 1, default = 5}, + + }, + required = {"token_endpoint"} +} + + +local _M = { + version = 0.1, + priority = 2000, + type = 'auth', + name = plugin_name, + schema = schema, +} + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + +local function is_path_protected(conf) + -- TODO if permissions are empty lazy load paths from Keycloak + if conf.permissions == nil then + return false + end + return true +end + + +local function evaluate_permissions(conf, token) + local url_decoded = url.parse(conf.token_endpoint) + local host = url_decoded.host + local port = url_decoded.port + + if not port then + if url_decoded.scheme == "https" then + port = 443 + else + port = 80 + end + end + + if not is_path_protected(conf) and conf.policy_enforcement_mode == "ENFORCING" then + core.response.exit(403) + return + end + + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local params = { + method = "POST", + body = ngx.encode_args({ + grant_type = conf.grant_type, + audience = conf.audience, + response_mode = "decision", + permission = conf.permissions + }), + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded", + ["Authorization"] = token + } + } + + if conf.keepalive then + params.keepalive_timeout = conf.keepalive_timeout + params.keepalive_pool = conf.keepalive_pool + else + params.keepalive = conf.keepalive + end + + local httpc_res, httpc_err = httpc:request_uri(conf.token_endpoint, params) + + if not httpc_res then + core.log.error("error while sending authz request to [", host ,"] port[", + tostring(port), "] ", httpc_err) + core.response.exit(500, httpc_err) + return + end + + if httpc_res.status >= 400 then + core.log.error("status code: ", httpc_res.status, " msg: ", httpc_res.body) + core.response.exit(httpc_res.status, httpc_res.body) + end +end + + +local function fetch_jwt_token(ctx) + local token = core.request.header(ctx, "authorization") + if not token then + return nil, "authorization header not available" + end + + local prefix = sub_str(token, 1, 7) + if prefix ~= 'Bearer ' and prefix ~= 'bearer ' then + return "Bearer " .. token + end + return token +end + + +function _M.rewrite(conf, ctx) + core.log.debug("hit keycloak-auth rewrite") + local jwt_token, err = fetch_jwt_token(ctx) + if not jwt_token then + core.log.error("failed to fetch JWT token: ", err) + return 401, {message = "Missing JWT token in request"} + end + + evaluate_permissions(conf, jwt_token) +end + + +return _M diff --git a/conf/config.yaml b/conf/config.yaml index a418386..d640ef7 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -167,6 +167,7 @@ plugins: # plugin list - http-logger - skywalking - echo + - authz-keycloak stream_plugins: - mqtt-proxy diff --git a/doc/images/plugin/authz-keycloak.png b/doc/images/plugin/authz-keycloak.png new file mode 100644 index 0000000..6b6ae84 Binary files /dev/null and b/doc/images/plugin/authz-keycloak.png differ diff --git a/doc/plugins/authz-keycloak.md b/doc/plugins/authz-keycloak.md new file mode 100644 index 0000000..47b19ae --- /dev/null +++ b/doc/plugins/authz-keycloak.md @@ -0,0 +1,135 @@ +<!-- +# +# 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. +# +--> + +[Chinese](authz-keycloak-cn.md) + +# Summary +- [**Name**](#name) +- [**Attributes**](#attributes) +- [**How To Enable**](#how-to-enable) +- [**Test Plugin**](#test-plugin) +- [**Disable Plugin**](#disable-plugin) +- [**Examples**](#examples) + + +## Name + +`authz-keycloak` is an authorization plugin to be used with the Keycloak Identity Server. Keycloak is an OAuth/OIDC and +UMA compliant Ideneity Server. Although, its developed to working in conjunction with Keycloak it should work with any +OAuth/OIDC and UMA compliant identity providers as well. + +For more information on JWT, refer to [Keycloak Authorization Docs](https://www.keycloak.org/docs/latest/authorization_services) for more information. + +## Attributes + +|Name |Requirement |Description| +|--------- |-------- |-----------| +| token_endpoint|required |A OAuth2-compliant Token Endpoint that supports the urn:ietf:params:oauth:grant-type:uma-ticket grant type.| +| grant_type |optional |Default value is `urn:ietf:params:oauth:grant-type:uma-ticket`.| +| audience |optional |The client identifier of the resource server to which the client is seeking access. This parameter is mandatory in case the permission parameter is defined.| +| permissions |optional |This parameter is optional. A string representing a set of one or more resources and scopes the client is seeking access. The format of the string must be: RESOURCE_ID#SCOPE_ID.| +| timeout |optional |Timeout for the http connection with the Identity Server. Default is 3 seconds| +| policy_enforcement_mode|required |Enforcing or Permissive.| + + +### Policy Enforcement Mode + +Specifies how policies are enforced when processing authorization requests sent to the server. + +**Enforcing** + +- (default mode) Requests are denied by default even when there is no policy associated with a given resource. + +**Permissive** + +- Requests are allowed even when there is no policy associated with a given resource. + + +## How To Enable + +Create a route and enable the authz-keycloak plugin on the route: + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/5 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/get", + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/{client_id}/protocol/openid-connect/token", + "permissions": ["resource name#scope name"], + "audience": "Client ID" + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +} +``` + + +## Test Plugin + +```shell +curl http://127.0.0.1:9080/get -H 'Authorization: Bearer {JWT Token}' +``` + + +## Disable Plugin + +Remove the corresponding json configuration in the plugin configuration to disable the `authz-keycloak`. +APISIX plugins are hot-reloaded, therefore no need to restart APISIX. + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/5 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/get", + "plugins": { + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:8080": 1 + } + } +} +``` + +## Examples + +Checkout the unit test for of the authz-keycloak.t to understand how the authorization policies can be integrated into your +API workflows. Run the following docker image and visit `http://localhost:8090` to view the associated policies for the unit tests. + +```bash +docker run -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -p 8090:8080 sshniro/keycloak-apisix +``` + +The following image shows how the policies are configures in the Keycloak server. + + + +## Future Development + +- Currently the authz-plugin requires to define the resource name and required scopes inorder to enforce policies for the routes. +However, Keycloak's official adapters (Java, JS) also provides path matching by querying Keycloak paths dynamically, and +lazy loading the paths to identify resources. Future version on authz-plugin will support this functionality. + +- Support to read scope and configurations from the Keycloak JSON File diff --git a/t/admin/plugins.t b/t/admin/plugins.t index fcaf082..1a5f06e 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -30,7 +30,7 @@ __DATA__ --- request GET /apisix/admin/plugins/list --- response_body_like eval -qr/\["limit-req","limit-count","limit-conn","key-auth","basic-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function","openid-connect","proxy-rewrite","redirect","response-rewrite","fault-injection","udp-logger","wolf-rbac","proxy-cache","tcp-logger","proxy-mirror","kafka-logger","cors","consumer-restriction","syslog","batch-requests","http-logger","skywalking","echo"\]/ +qr/\["limit-req","limit-count","limit-conn","key-auth","basic-auth","prometheus","node-status","jwt-auth","zipkin","ip-restriction","grpc-transcode","serverless-pre-function","serverless-post-function","openid-connect","proxy-rewrite","redirect","response-rewrite","fault-injection","udp-logger","wolf-rbac","proxy-cache","tcp-logger","proxy-mirror","kafka-logger","cors","consumer-restriction","syslog","batch-requests","http-logger","skywalking","echo","authz-keycloak"\]/ --- no_error_log [error] diff --git a/t/debug/debug-mode.t b/t/debug/debug-mode.t index 7babd48..2924cdc 100644 --- a/t/debug/debug-mode.t +++ b/t/debug/debug-mode.t @@ -66,6 +66,7 @@ loaded plugin and sort by priority: 2520 name: basic-auth loaded plugin and sort by priority: 2510 name: jwt-auth loaded plugin and sort by priority: 2500 name: key-auth loaded plugin and sort by priority: 2400 name: consumer-restriction +loaded plugin and sort by priority: 2000 name: authz-keycloak loaded plugin and sort by priority: 1010 name: proxy-mirror loaded plugin and sort by priority: 1009 name: proxy-cache loaded plugin and sort by priority: 1008 name: proxy-rewrite diff --git a/t/plugin/authz-keycloak.t b/t/plugin/authz-keycloak.t new file mode 100644 index 0000000..c3361b5 --- /dev/null +++ b/t/plugin/authz-keycloak.t @@ -0,0 +1,349 @@ +# +# 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('debug'); +repeat_each(1); +no_long_string(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({ + token_endpoint = "https://efactory-security-portal.salzburgresearch.at/", + grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + +=== TEST 2: full schema check +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({token_endpoint = "https://efactory-security-portal.salzburgresearch.at/", + permissions = {"res:customer#scopes:view"}, + timeout = 1000, + audience = "University", + grant_type = "urn:ietf:params:oauth:grant-type:uma-ticket" + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + +=== TEST 3: token_endpoint missing +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.authz-keycloak") + local ok, err = plugin.check_schema({permissions = {"res:customer#scopes:view"}}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +property "token_endpoint" is required +done +--- no_error_log +[error] + + +=== TEST 4: add plugin with view course permissions +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#view"], + "audience": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#view"], + "audience": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: Get access token for teacher and access view course route +--- config + location /t { + content_by_lua_block { + local json_decode = require("cjson").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=teac...@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 200 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- no_error_log +[error] + + +=== TEST 6: invalid access token +--- config + location /t { + content_by_lua_block { + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer wrong_token", + } + }) + if res.status == 401 then + ngx.say(true) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +Invalid bearer token + + + +=== TEST 7: add plugin for delete course route +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#delete"], + "audience": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }]], + [[{ + "node": { + "value": { + "plugins": { + "authz-keycloak": { + "token_endpoint": "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token", + "permissions": ["course_resource#delete"], + "audience": "course_management", + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "timeout": 3000 + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1982": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello1" + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 8: Get access token for student and delete course +--- config + location /t { + content_by_lua_block { + local json_decode = require("cjson").decode + local http = require "resty.http" + local httpc = http.new() + local uri = "http://127.0.0.1:8090/auth/realms/University/protocol/openid-connect/token" + local res, err = httpc:request_uri(uri, { + method = "POST", + body = "grant_type=password&client_id=course_management&client_secret=d1ec69e9-55d2-4109-a3ea-befa071579d5&username=stud...@gmail.com&password=123456", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded" + } + }) + + if res.status == 200 then + local body = json_decode(res.body) + local accessToken = body["access_token"] + + + uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello1" + local res, err = httpc:request_uri(uri, { + method = "GET", + headers = { + ["Authorization"] = "Bearer " .. accessToken, + } + }) + + if res.status == 403 then + ngx.say(true) + else + ngx.say(false) + end + else + ngx.say(false) + end + } + } +--- request +GET /t +--- response_body +true +--- error_log +{"error":"access_denied","error_description":"not_authorized"}