You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by sp...@apache.org on 2022/01/11 02:38:43 UTC

[apisix] branch master updated: feat: add forward-auth plugin (#6037)

This is an automated email from the ASF dual-hosted git repository.

spacewander pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 8dbdd1f  feat: add forward-auth plugin (#6037)
8dbdd1f is described below

commit 8dbdd1f68812482004509085e66987aafff07f6e
Author: Zeping Bai <bz...@apache.org>
AuthorDate: Tue Jan 11 10:38:35 2022 +0800

    feat: add forward-auth plugin (#6037)
    
    Co-authored-by: 罗泽轩 <sp...@gmail.com>
---
 apisix/plugins/forward-auth.lua        | 140 +++++++++++++++++++
 conf/config-default.yaml               |   1 +
 docs/en/latest/config.json             |   3 +-
 docs/en/latest/plugins/forward-auth.md | 139 ++++++++++++++++++
 t/admin/plugins.t                      |   1 +
 t/plugin/forward-auth.t                | 248 +++++++++++++++++++++++++++++++++
 6 files changed, 531 insertions(+), 1 deletion(-)

diff --git a/apisix/plugins/forward-auth.lua b/apisix/plugins/forward-auth.lua
new file mode 100644
index 0000000..ed3baef
--- /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 e1ae179..e2757d8 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 3f86445..93eba03 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -68,7 +68,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 0000000..6dad713
--- /dev/null
+++ b/docs/en/latest/plugins/forward-auth.md
@@ -0,0 +1,139 @@
+---
+title: forward-auth
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+## 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 29038ea..d203c37 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 0000000..f379fff
--- /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