diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 000000000000..b8c2cde69bd8 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime": { + "version": "LuaJIT", + "path": [ + "?.lua", + "?/init.lua", + "apisix/?.lua", + "apisix/?/init.lua", + "t/lib/?.lua", + "t/lib/?/init.lua" + ] + }, + "diagnostics": { + "globals": [ + "ngx", + "jit", + "arg", + "lua_load" + ] + }, + "workspace": { + "checkThirdParty": false + } +} diff --git a/apisix/consumer.lua b/apisix/consumer.lua index cd60a0209ff0..a5f1e3c120e4 100644 --- a/apisix/consumer.lua +++ b/apisix/consumer.lua @@ -339,6 +339,30 @@ local function get_anonymous_consumer_from_local_cache(name) end +local function get_consumer_from_local_cache(name) + local consumer_raw = consumers:get(name) + + if not consumer_raw or not consumer_raw.value or + not consumer_raw.value.id or not consumer_raw.modifiedIndex then + return nil, nil, "failed to get consumer " .. name + end + + local consumer = consumer_raw.value + consumer.consumer_name = consumer_raw.value.id + consumer.modifiedIndex = consumer_raw.modifiedIndex + + if consumer.labels then + consumer.custom_id = consumer.labels["custom_id"] + end + + local consumer_conf = { + conf_version = consumer_raw.modifiedIndex + } + + return consumer, consumer_conf +end + + function _M.get_anonymous_consumer(name) local anon_consumer, anon_consumer_conf, err anon_consumer, anon_consumer_conf, err = get_anonymous_consumer_from_local_cache(name) @@ -347,4 +371,12 @@ function _M.get_anonymous_consumer(name) end +function _M.get_consumer_by_name(name) + local consumer, consumer_conf, err + consumer, consumer_conf, err = get_consumer_from_local_cache(name) + + return consumer, consumer_conf, err +end + + return _M diff --git a/apisix/plugins/forward-auth.lua b/apisix/plugins/forward-auth.lua index 2f25c7ed9e83..b688cdca055e 100644 --- a/apisix/plugins/forward-auth.lua +++ b/apisix/plugins/forward-auth.lua @@ -18,16 +18,17 @@ local ipairs = ipairs local core = require("apisix.core") local http = require("resty.http") +local consumer = require("apisix.consumer") local pairs = pairs local type = type local tostring = tostring -local schema = { +local schema = { type = "object", properties = { - uri = {type = "string"}, - allow_degradation = {type = "boolean", default = false}, - status_on_error = {type = "integer", minimum = 200, maximum = 599, default = 403}, + uri = { type = "string" }, + allow_degradation = { type = "boolean", default = false }, + status_on_error = { type = "integer", minimum = 200, maximum = 599, default = 403 }, ssl_verify = { type = "boolean", default = true, @@ -35,13 +36,13 @@ local schema = { request_method = { type = "string", default = "GET", - enum = {"GET", "POST"}, + enum = { "GET", "POST" }, description = "the method for client to request the authorization service" }, request_headers = { type = "array", default = {}, - items = {type = "string"}, + items = { type = "string" }, description = "client request header that will be sent to the authorization service" }, extra_headers = { @@ -51,25 +52,31 @@ local schema = { ["^[^:]+$"] = { type = "string", description = "header value as a string; may contain variables" - .. "like $remote_addr, $request_uri" + .. "like $remote_addr, $request_uri" } }, description = "extra headers sent to the authorization service; " - .. "values must be strings and can include variables" - .. "like $remote_addr, $request_uri." + .. "values must be strings and can include variables" + .. "like $remote_addr, $request_uri." }, upstream_headers = { type = "array", default = {}, - items = {type = "string"}, + items = { type = "string" }, description = "authorization response header that will be sent to the upstream" }, client_headers = { type = "array", default = {}, - items = {type = "string"}, + items = { type = "string" }, description = "authorization response header that will be sent to" - .. "the client when authorizing failed" + .. "the client when authorizing failed" + }, + consumer_header = { + type = "string", + minLength = 1, + description = "authorization response header that contains the " + .. "APISIX Consumer username to attach to the request" }, timeout = { type = "integer", @@ -78,11 +85,11 @@ local schema = { 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}, + keepalive = { type = "boolean", default = true }, + keepalive_timeout = { type = "integer", minimum = 1000, default = 60000 }, + keepalive_pool = { type = "integer", minimum = 1, default = 5 }, }, - required = {"uri"} + required = { "uri" } } @@ -95,15 +102,42 @@ local _M = { function _M.check_schema(conf) - local check = {"uri"} + local check = { "uri" } core.utils.check_https(check, conf, _M.name) - core.utils.check_tls_bool({"ssl_verify"}, conf, _M.name) + core.utils.check_tls_bool({ "ssl_verify" }, conf, _M.name) return core.schema.check(schema, conf) end +local function attach_consumer_by_header(conf, ctx, res) + if not conf.consumer_header then + return + end + + local consumer_name = res.headers[conf.consumer_header] + if type(consumer_name) == "table" then + consumer_name = consumer_name[1] + end + + if not consumer_name or consumer_name == "" then + core.log.error("missing consumer header from auth response: ", conf.consumer_header) + return 403, "consumer header missing in auth response" + end + + local auth_consumer, consumer_conf, err = consumer.get_consumer_by_name(consumer_name) + if not auth_consumer then + core.log.error("failed to fetch consumer by name from auth response header ", + conf.consumer_header, ": ", err) + return 403, "consumer not found" + end + + consumer.attach_consumer(ctx, auth_consumer, consumer_conf) +end + + +local function do_auth(conf, ctx) + ctx.forward_auth_processed = true -function _M.access(conf, ctx) local auth_headers = { ["X-Forwarded-Proto"] = core.request.get_scheme(ctx), ["X-Forwarded-Method"] = core.request.get_method(), @@ -130,7 +164,7 @@ function _M.access(conf, ctx) end if err then core.log.error("failed to resolve variable in extra header '", - header, "': ",value,": ",err) + header, "': ", value, ": ", err) end end end @@ -184,8 +218,12 @@ function _M.access(conf, ctx) return res.status, res.body end - -- set headers from the auth response, clearing any client-supplied values - -- for configured headers not present in the auth response + local code, body = attach_consumer_by_header(conf, ctx, res) + if code or body then + return code, 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 is nil, the client header's value will be removed if it exists @@ -194,4 +232,16 @@ function _M.access(conf, ctx) end +function _M.rewrite(conf, ctx) + return do_auth(conf, ctx) +end + +function _M.access(conf, ctx) + if ctx.forward_auth_processed then + return + end + + return do_auth(conf, ctx) +end + return _M diff --git a/docs/en/latest/plugins/forward-auth.md b/docs/en/latest/plugins/forward-auth.md index 18f0a661cde7..787dbd9b7b91 100644 --- a/docs/en/latest/plugins/forward-auth.md +++ b/docs/en/latest/plugins/forward-auth.md @@ -55,6 +55,7 @@ The `forward-auth` Plugin supports the integration with an external authorizatio | request_headers | array | False | | | Client request headers that should be forwarded to the external authorization service. If not configured, only headers added by APISIX are forwarded, such as `X-Forwarded-*`. | | upstream_headers | array | False | | | External authorization service response headers that should be forwarded to the Upstream service. If not configured, no headers are forwarded to the Upstream service. | | client_headers | array | False | | | External authorization service response headers that should be forwarded to the client when authentication fails. If not configured, no headers are forwarded to the client. | +| consumer_header | string | False | | | External authorization service response header that contains an existing APISIX Consumer username. When set and the auth response is 2xx, APISIX attaches that Consumer to the request. If the header is missing or the Consumer does not exist, the request is rejected with HTTP `403`. | | extra_headers | object | False | | | Additional headers to send to the authorization service. Support [NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html) in values. | | timeout | integer | False | 3000 | between 1 and 60000 inclusive | Timeout for the external authorization service HTTP call in milliseconds. | | keepalive | boolean | False | true | | If true, keep the connections open for multiple requests. | @@ -67,6 +68,8 @@ The `forward-auth` Plugin supports the integration with an external authorizatio The examples below demonstrate how you can use `forward-auth` for different scenarios. +If `consumer_header` is configured and the authorization service returns a 2xx response, APISIX reads the configured response header as a Consumer username and attaches the corresponding Consumer to the current request. This allows Consumer and Consumer Group plugins to take effect for the request. If the header is missing or the Consumer does not exist in APISIX, the request is rejected with HTTP `403`. + :::note You can fetch the `admin_key` from `config.yaml` and save to an environment variable with the following command: @@ -506,6 +509,52 @@ You should see an `HTTP/1.1 200 OK` response of the following, showing the heade } ``` +### Attach an APISIX Consumer from the Authorization Response + +Create a Consumer that already exists in APISIX: + +```shell +curl -X PUT 'http://127.0.0.1:9180/apisix/admin/consumers' \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "demo-consumer", + "plugins": { + "limit-count": { + "count": 1, + "time_window": 60, + "rejected_code": 429, + "key": "remote_addr", + "policy": "local" + } + } + }' +``` + +Have the authorization service return the Consumer username in a response header such as `X-Consumer-Username`, and configure the Plugin to read it: + +```shell +curl -X PUT 'http://127.0.0.1:9180/apisix/admin/routes/consumer-route' \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/consumer-route", + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:9080/auth", + "request_headers": ["Authorization"], + "consumer_header": "X-Consumer-Username" + } + }, + "upstream": { + "nodes": { + "httpbin.org:80": 1 + }, + "type": "roundrobin" + } + }' +``` + +With this configuration, a successful auth response like `X-Consumer-Username: demo-consumer` attaches `demo-consumer` to the request. APISIX then applies Consumer-scoped plugins, such as the `limit-count` policy above. + ### Return Designated Headers to Clients on Authentication Failure The following example demonstrates how you can configure `forward-auth` on a Route to regulate client access to the Upstream resources. It also passes a specific header returned by the authorization service to the client when the authentication fails. diff --git a/docs/zh/latest/plugins/forward-auth.md b/docs/zh/latest/plugins/forward-auth.md index ab1b7f9cbb1d..a1664002f2e8 100644 --- a/docs/zh/latest/plugins/forward-auth.md +++ b/docs/zh/latest/plugins/forward-auth.md @@ -55,6 +55,7 @@ import TabItem from '@theme/TabItem'; | request_headers | array | 否 | | | 需要转发给外部授权服务的客户端请求头。如果未配置,则只转发 APISIX 添加的请求头,例如 `X-Forwarded-*`。 | | upstream_headers | array | 否 | | | 认证通过时,需要转发到 Upstream 服务的外部授权服务响应头。如果未配置,则不转发任何请求头。 | | client_headers | array | 否 | | | 认证失败时,需要转发给客户端的外部授权服务响应头。如果未配置,则不转发任何响应头。 | +| consumer_header | string | 否 | | | 外部授权服务响应头中包含已存在 APISIX Consumer 用户名的字段。配置后,当认证服务返回 2xx 响应时,APISIX 会把该 Consumer 绑定到当前请求。如果响应头缺失,或 APISIX 中不存在对应 Consumer,请求会被拒绝并返回 HTTP `403`。 | | extra_headers | object | 否 | | | 发送给授权服务的额外请求头,支持在值中使用 [NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html)。 | | timeout | integer | 否 | 3000 | 1 到 60000 之间(含) | 外部授权服务 HTTP 调用的超时时间(毫秒)。 | | keepalive | boolean | 否 | true | | 如果为 true,则保持连接以处理多个请求。 | @@ -67,6 +68,8 @@ import TabItem from '@theme/TabItem'; 以下示例演示了如何针对不同场景使用 `forward-auth`。 +如果配置了 `consumer_header`,并且认证服务返回了 2xx 响应,APISIX 会把该响应头的值当作 Consumer 用户名,从本地已有的 Consumer 配置中查找并绑定到当前请求。这样 Consumer 和 Consumer Group 上配置的插件也会对该请求生效。如果响应头缺失,或 APISIX 中不存在对应 Consumer,请求会被拒绝并返回 HTTP `403`。 + :::note 你可以这样从 `config.yaml` 中获取 `admin_key` 并存入环境变量: @@ -506,6 +509,52 @@ curl "http://127.0.0.1:9080/headers" -H 'Authorization: 321' } ``` +### 通过认证响应绑定 APISIX Consumer + +先在 APISIX 中创建一个已存在的 Consumer: + +```shell +curl -X PUT 'http://127.0.0.1:9180/apisix/admin/consumers' \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "username": "demo-consumer", + "plugins": { + "limit-count": { + "count": 1, + "time_window": 60, + "rejected_code": 429, + "key": "remote_addr", + "policy": "local" + } + } + }' +``` + +然后让认证服务在成功响应中返回类似 `X-Consumer-Username` 的响应头,并在插件中声明该响应头: + +```shell +curl -X PUT 'http://127.0.0.1:9180/apisix/admin/routes/consumer-route' \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/consumer-route", + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:9080/auth", + "request_headers": ["Authorization"], + "consumer_header": "X-Consumer-Username" + } + }, + "upstream": { + "nodes": { + "httpbin.org:80": 1 + }, + "type": "roundrobin" + } + }' +``` + +在这个配置下,如果认证服务成功返回 `X-Consumer-Username: demo-consumer`,APISIX 就会把 `demo-consumer` 绑定到当前请求,并继续执行该 Consumer 上配置的插件,例如上面的 `limit-count`。 + ### 认证失败时向客户端返回指定请求头 以下示例演示了如何在路由上配置 `forward-auth`,控制客户端对上游资源的访问,并在认证失败时将授权服务返回的特定请求头传递给客户端。 diff --git a/t/plugin/forward-auth.t b/t/plugin/forward-auth.t index 450bece825a2..ed9a6f52812c 100644 --- a/t/plugin/forward-auth.t +++ b/t/plugin/forward-auth.t @@ -42,7 +42,9 @@ __DATA__ {uri = 3233}, {uri = "http://127.0.0.1:8199", request_headers = "test"}, {uri = "http://127.0.0.1:8199", request_method = "POST"}, - {uri = "http://127.0.0.1:8199", request_method = "PUT"} + {uri = "http://127.0.0.1:8199", request_method = "PUT"}, + {uri = "http://127.0.0.1:8199", consumer_header = "X-Consumer-Username"}, + {uri = "http://127.0.0.1:8199", consumer_header = 123} } local plugin = require("apisix.plugins.forward-auth") @@ -59,6 +61,8 @@ property "uri" validation failed: wrong type: expected string, got number property "request_headers" validation failed: wrong type: expected array, got string done property "request_method" validation failed: matches none of the enum values +done +property "consumer_header" validation failed: wrong type: expected string, got number @@ -133,6 +137,21 @@ property "request_method" validation failed: matches none of the enum values end end end]], + [[return function(conf, ctx) + local core = require("apisix.core"); + local authorization = core.request.header(ctx, "Authorization") + if authorization == "999" then + core.response.set_header("X-Consumer-Username", + "forward-auth-consumer"); + core.response.exit(200); + elseif authorization == "990" then + core.response.exit(200); + elseif authorization == "991" then + core.response.set_header("X-Consumer-Username", + "ghost-consumer"); + core.response.exit(200); + end + end]], [[return function(conf, ctx) local core = require("apisix.core") if core.request.get_method() == "POST" then @@ -161,6 +180,21 @@ property "request_method" validation failed: matches none of the enum values uri = "/auth" }, }, + { + url = "/apisix/admin/consumers", + data = [[{ + "username": "forward-auth-consumer", + "plugins": { + "limit-count": { + "count": 1, + "time_window": 60, + "rejected_code": 429, + "key": "remote_addr", + "policy": "local" + } + } + }]], + }, { url = "/apisix/admin/routes/echo", data = [[{ @@ -356,6 +390,57 @@ property "request_method" validation failed: matches none of the enum values "upstream_id": "u1", "uri": "/crlf" }]] + }, + { + url = "/apisix/admin/routes/12", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"], + "consumer_header": "X-Consumer-Username" + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/consumer-headers" + }]] + }, + { + url = "/apisix/admin/routes/13", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"], + "consumer_header": "X-Consumer-Username" + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/consumer-missing" + }]] + }, + { + url = "/apisix/admin/routes/14", + data = [[{ + "plugins": { + "forward-auth": { + "uri": "http://127.0.0.1:1984/auth", + "request_headers": ["Authorization"], + "consumer_header": "X-Consumer-Username" + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/consumer-unknown" + }]] } } @@ -368,7 +453,7 @@ property "request_method" validation failed: matches none of the enum values } } --- response_body eval -"passed\n" x 13 +"passed\n" x 17 @@ -507,9 +592,6 @@ GET /ping3 Authorization: 888 --- response_body_like eval qr/\"x-user-id\":\"i-am-an-user\"/ - - - === TEST 16: block CRLF header injection --- request GET /crlf?user=guest%0d%0ax-user1:%20admin @@ -518,3 +600,44 @@ Authorization: 111 --- error_code: 403 --- error_log failed to process forward auth, err: invalid characters found in header value, + + + +=== TEST 17: attach consumer from auth response header +--- request +GET /consumer-headers +--- more_headers +Authorization: 999 +--- response_body_like eval +qr/\"x-consumer-username\":\"forward-auth-consumer\"/ + + + +=== TEST 18: run consumer plugins after auth response header attaches consumer +--- request +GET /consumer-headers +--- more_headers +Authorization: 999 +--- error_code: 429 + + + +=== TEST 19: reject when configured consumer header is missing +--- request +GET /consumer-missing +--- more_headers +Authorization: 990 +--- error_code: 403 +--- response_body +consumer header missing in auth response + + + +=== TEST 20: reject when configured consumer does not exist +--- request +GET /consumer-unknown +--- more_headers +Authorization: 991 +--- error_code: 403 +--- response_body +consumer not found