diff --git a/apisix/plugins/forward-auth.lua b/apisix/plugins/forward-auth.lua new file mode 100644 index 000000000000..ed3baef4153d --- /dev/null +++ b/apisix/plugins/forward-auth.lua @@ -0,0 +1,140 @@ +-- +-- 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 ipairs = ipairs +local core = require("apisix.core") +local http = require("resty.http") + +local schema = { + type = "object", + properties = { + host = {type = "string"}, + ssl_verify = { + type = "boolean", + default = true, + }, + request_headers = { + type = "array", + default = {}, + items = {type = "string"}, + description = "client request header that will be sent to the authorization service" + }, + upstream_headers = { + type = "array", + default = {}, + items = {type = "string"}, + description = "authorization response header that will be sent to the upstream" + }, + client_headers = { + type = "array", + default = {}, + items = {type = "string"}, + description = "authorization response header that will be sent to" + .. "the client when authorizing failed" + }, + timeout = { + type = "integer", + minimum = 1, + maximum = 60000, + default = 3000, + description = "timeout in milliseconds", + }, + keepalive = {type = "boolean", default = true}, + keepalive_timeout = {type = "integer", minimum = 1000, default = 60000}, + keepalive_pool = {type = "integer", minimum = 1, default = 5}, + }, + required = {"host"} +} + + +local _M = { + version = 0.1, + priority = 2002, + name = "forward-auth", + schema = schema, +} + + +function _M.check_schema(conf) + return core.schema.check(schema, conf) +end + + +function _M.access(conf, ctx) + local auth_headers = { + ["X-Forwarded-Proto"] = core.request.get_scheme(ctx), + ["X-Forwarded-Method"] = core.request.get_method(), + ["X-Forwarded-Host"] = core.request.get_host(ctx), + ["X-Forwarded-Uri"] = ctx.var.request_uri, + ["X-Forwarded-For"] = core.request.get_remote_client_ip(ctx), + } + + -- append headers that need to be get from the client request header + if #conf.request_headers > 0 then + for _, header in ipairs(conf.request_headers) do + if not auth_headers[header] then + auth_headers[header] = core.request.header(ctx, header) + end + end + end + + local params = { + headers = auth_headers, + keepalive = conf.keepalive, + ssl_verify = conf.ssl_verify + } + + if conf.keepalive then + params.keepalive_timeout = conf.keepalive_timeout + params.keepalive_pool = conf.keepalive_pool + end + + local httpc = http.new() + httpc:set_timeout(conf.timeout) + + local res, err = httpc:request_uri(conf.host, params) + + -- block by default when authorization service is unavailable + if not res then + core.log.error("failed to process forward auth, err: ", err) + return 403 + end + + if res.status >= 300 then + local client_headers = {} + + if #conf.client_headers > 0 then + for _, header in ipairs(conf.client_headers) do + client_headers[header] = res.headers[header] + end + end + + core.response.set_header(client_headers) + return res.status, res.body + end + + -- append headers that need to be get from the auth response header + for _, header in ipairs(conf.upstream_headers) do + local header_value = res.headers[header] + if header_value then + core.request.set_header(ctx, header, header_value) + end + end +end + + +return _M diff --git a/conf/config-default.yaml b/conf/config-default.yaml index cbf97dd66931..7833c9da7293 100644 --- a/conf/config-default.yaml +++ b/conf/config-default.yaml @@ -344,6 +344,7 @@ plugins: # plugin list (sorted by priority) - jwt-auth # priority: 2510 - key-auth # priority: 2500 - consumer-restriction # priority: 2400 + - forward-auth # priority: 2002 - opa # priority: 2001 - authz-keycloak # priority: 2000 #- error-log-logger # priority: 1091 diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index dcbfe682cb0c..e6a55bc09ea2 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -67,7 +67,8 @@ "plugins/hmac-auth", "plugins/authz-casbin", "plugins/ldap-auth", - "plugins/opa" + "plugins/opa", + "plugins/forward-auth" ] }, { diff --git a/docs/en/latest/plugins/forward-auth.md b/docs/en/latest/plugins/forward-auth.md new file mode 100644 index 000000000000..6dad71384de4 --- /dev/null +++ b/docs/en/latest/plugins/forward-auth.md @@ -0,0 +1,139 @@ +--- +title: forward-auth +--- + + + +## Summary + +- [**Description**](#description) +- [**Attributes**](#attributes) +- [**Data Definition**](#data-definition) +- [**Example**](#example) + +## Description + +The `forward-auth` plugin implements a classic external authentication model. We can implement a custom error return or user redirection to the authentication page if the authentication fails. + +Forward Auth cleverly moves the authentication and authorization logic to a dedicated external service, where the gateway forwards the user's request to the authentication service and blocks the original request, and replaces the result when the authentication service responds with a non-2xx status. + +## Attributes + +| Name | Type | Requirement | Default | Valid | Description | +| -- | -- | -- | -- | -- | -- | +| host | string | required | | | Authorization service host (eg. https://localhost:9188) | +| ssl_verify | boolean | optional | true | | Whether to verify the certificate | +| request_headers | array[string] | optional | | | `client` request header that will be sent to the `authorization` service. When it is not set, no `client` request headers are sent to the `authorization` service, except for those provided by APISIX (X-Forwarded-XXX). | +| upstream_headers | array[string] | optional | | | `authorization` service response header that will be sent to the `upstream`. When it is not set, will not forward the `authorization` service response header to the `upstream`. | +| client_headers | array[string] | optional | | | `authorization` response header that will be sent to the `client` when authorize failure. When it is not set, will not forward the `authorization` service response header to the `client`. | +| timeout | integer | optional | 3000ms | [1, 60000]ms | Authorization service HTTP call timeout | +| keepalive | boolean | optional | true | | HTTP keepalive | +| keepalive_timeout | integer | optional | 60000ms | [1000, ...]ms | keepalive idle timeout | +| keepalive_pool | integer | optional | 5 | [1, ...]ms | Connection pool limit | + +## Data Definition + +The request headers in the following list will have APISIX generated and sent to the `authorization` service. + +| Scheme | HTTP Method | Host | URI | Source IP | +| -- | -- | -- | -- | -- | +| X-Forwarded-Proto | X-Forwarded-Method | X-Forwarded-Host | X-Forwarded-Uri | X-Forwarded-For | + +## Example + +First, you need to setup an external authorization service. Here is an example of using Apache APISIX's serverless plugin to mock. + +```shell +$ curl -X PUT 'http://127.0.0.1:9080/apisix/admin/routes/auth' \ + -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' \ + -H 'Content-Type: application/json' \ + -d '{ + "uri": "/auth", + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function (conf, ctx) local core = require(\"apisix.core\"); local authorization = core.request.header(ctx, \"Authorization\"); if authorization == \"123\" then core.response.exit(200); elseif authorization == \"321\" then core.response.set_header(\"X-User-ID\", \"i-am-user\"); core.response.exit(200); else core.response.set_header(\"Location\", \"http://example.com/auth\"); core.response.exit(403); end end" + ] + } + } +}' +``` + +Next, we create a route for testing. + +```shell +$ curl -X PUT http://127.0.0.1:9080/apisix/admin/routes/1 + -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' + -d '{ + "uri": "/headers", + "plugins": { + "forward-auth": { + "host": "http://127.0.0.1:9080/auth", + "request_headers": ["Authorization"], + "upstream_headers": ["X-User-ID"], + "client_headers": ["Location"] + } + }, + "upstream": { + "nodes": { + "httpbin.org:80": 1 + }, + "type": "roundrobin" + } +}' +``` + +We can perform the following three tests. + +1. **request_headers** Send Authorization header from `client` to `authorization` service + +```shell +$ curl http://127.0.0.1:9080/headers -H 'Authorization: 123' +{ + "headers": { + "Authorization": "123", + "Next": "More-headers" + } +} +``` + +2. **upstream_headers** Send `authorization` service response header to the `upstream` + +```shell +$ curl http://127.0.0.1:9080/headers -H 'Authorization: 321' +{ + "headers": { + "Authorization": "321", + "X-User-ID": "i-am-user", + "Next": "More-headers" + } +} +``` + +3. **client_headers** Send `authorization` service response header to `client` when authorizing failed + +```shell +$ curl -i http://127.0.0.1:9080/headers +HTTP/1.1 403 Forbidden +Location: http://example.com/auth +``` + +Finally, you can disable the `forward-auth` plugin by removing it from the route. diff --git a/t/admin/plugins.t b/t/admin/plugins.t index 2495b5395185..9e8e4e804103 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -83,6 +83,7 @@ basic-auth jwt-auth key-auth consumer-restriction +forward-auth opa authz-keycloak proxy-mirror diff --git a/t/plugin/forward-auth.t b/t/plugin/forward-auth.t new file mode 100644 index 000000000000..f379fff16bf7 --- /dev/null +++ b/t/plugin/forward-auth.t @@ -0,0 +1,248 @@ +# +# 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'; + +repeat_each(1); +no_long_string(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local test_cases = { + {host = "http://127.0.0.1:8199"}, + {request_headers = {"test"}}, + {host = 3233}, + {host = "http://127.0.0.1:8199", request_headers = "test"} + } + local plugin = require("apisix.plugins.forward-auth") + + for _, case in ipairs(test_cases) do + local ok, err = plugin.check_schema(case) + ngx.say(ok and "done" or err) + end + } + } +--- response_body +done +property "host" is required +property "host" validation failed: wrong type: expected string, got number +property "request_headers" validation failed: wrong type: expected array, got string + + + +=== TEST 2: setup route with plugin +--- config + location /t { + content_by_lua_block { + local datas = { + { + url = "/apisix/admin/upstreams/u1", + data = [[{ + "nodes": { + "127.0.0.1:1984": 1 + }, + "type": "roundrobin" + }]], + }, + { + url = "/apisix/admin/routes/auth", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"111\" then + core.response.exit(200); + end + end", + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"222\" then + core.response.set_header(\"X-User-ID\", \"i-am-an-user\"); + core.response.exit(200); + end + end",]] .. [[ + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"333\" then + core.response.set_header(\"Location\", \"http://example.com/auth\"); + core.response.exit(403); + end + end", + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"444\" then + core.response.exit(403, core.request.headers(ctx)); + end + end" + ] + } + }, + "uri": "/auth" + }]], + }, + { + url = "/apisix/admin/routes/echo", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function (conf, ctx) + local core = require(\"apisix.core\"); + core.response.exit(200, core.request.headers(ctx)); + end" + ] + } + }, + "uri": "/echo" + }]], + }, + { + url = "/apisix/admin/routes/1", + data = [[{ + "plugins": { + "forward-auth": { + "host": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"], + "upstream_headers": ["X-User-ID"], + "client_headers": ["Location"] + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/hello" + }]], + }, + { + url = "/apisix/admin/routes/2", + data = [[{ + "plugins": { + "forward-auth": { + "host": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"] + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/empty" + }]], + }, + } + + local t = require("lib.test_admin").test + + for _, data in ipairs(datas) do + local code, body = t(data.url, ngx.HTTP_PUT, data.data) + ngx.say(code..body) + end + } + } +--- response_body eval +"201passed\n" x 5 + + + +=== TEST 3: hit route (test request_headers) +--- request +GET /hello +--- more_headers +Authorization: 111 +--- response_body_like eval +qr/\"authorization\":\"111\"/ + + + +=== TEST 4: hit route (test upstream_headers) +--- request +GET /hello +--- more_headers +Authorization: 222 +--- response_body_like eval +qr/\"x-user-id\":\"i-am-an-user\"/ + + + +=== TEST 5: hit route (test client_headers) +--- request +GET /hello +--- more_headers +Authorization: 333 +--- error_code: 403 +--- response_headers +Location: http://example.com/auth + + + +=== TEST 6: hit route (check APISIX generated headers and ignore client headers) +--- request +GET /hello +--- more_headers +Authorization: 444 +X-Forwarded-Host: apisix.apache.org +--- error_code: 403 +--- response_body eval +qr/\"x-forwarded-proto\":\"http\"/ and qr/\"x-forwarded-method\":\"GET\"/ and +qr/\"x-forwarded-host\":\"localhost\"/ and qr/\"x-forwarded-uri\":\"\\\/hello\"/ and +qr/\"x-forwarded-for\":\"127.0.0.1\"/ +--- response_body_unlike eval +qr/\"x-forwarded-host\":\"apisix.apache.org\"/ + + + +=== TEST 7: hit route (not send upstream headers) +--- request +GET /empty +--- more_headers +Authorization: 222 +--- response_body_unlike eval +qr/\"x-user-id\":\"i-am-an-user\"/ + + + +=== TEST 8: hit route (not send client headers) +--- request +GET /empty +--- more_headers +Authorization: 333 +--- error_code: 403 +--- response_headers +!Location