This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 8cdb55217 feat: add feishu-auth plugin (#13382)
8cdb55217 is described below
commit 8cdb55217f1399c828e28800f849e6a216810d6d
Author: AlinsRan <[email protected]>
AuthorDate: Wed May 27 08:26:28 2026 +0800
feat: add feishu-auth plugin (#13382)
---
apisix/cli/config.lua | 1 +
apisix/plugins/feishu-auth.lua | 294 +++++++++++++++
conf/config.yaml.example | 1 +
docs/en/latest/config.json | 1 +
docs/en/latest/plugins/feishu-auth.md | 128 +++++++
docs/zh/latest/config.json | 1 +
docs/zh/latest/plugins/feishu-auth.md | 125 +++++++
t/admin/plugins.t | 1 +
t/plugin/feishu-auth.t | 678 ++++++++++++++++++++++++++++++++++
9 files changed, 1230 insertions(+)
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index f7121e1d1..c4003636b 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -225,6 +225,7 @@ local _M = {
"jwe-decrypt",
"key-auth",
"dingtalk-auth",
+ "feishu-auth",
"acl",
"consumer-restriction",
"attach-consumer-label",
diff --git a/apisix/plugins/feishu-auth.lua b/apisix/plugins/feishu-auth.lua
new file mode 100644
index 000000000..998ef1947
--- /dev/null
+++ b/apisix/plugins/feishu-auth.lua
@@ -0,0 +1,294 @@
+--
+-- 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 session = require("resty.session")
+
+local base64_encode = ngx.encode_base64
+local ngx_time = ngx.time
+local type = type
+
+local DEFAULT_TOKEN_URL =
"https://open.feishu.cn/open-apis/authen/v2/oauth/token"
+local DEFAULT_USERINFO_URL =
"https://open.feishu.cn/open-apis/authen/v1/user_info"
+
+local schema = {
+ type = "object",
+ properties = {
+ app_id = {type = "string", minLength = 1},
+ app_secret = {type = "string", minLength = 1},
+ code_header = {
+ type = "string",
+ description = "Header name to extract authorization code from.",
+ default = "X-Feishu-Code"
+ },
+ code_query = {
+ type = "string",
+ description = "Query parameter name to extract authorization code
from.",
+ default = "code"
+ },
+ userinfo_url = {
+ type = "string",
+ default = DEFAULT_USERINFO_URL
+ },
+ access_token_url = {
+ type = "string",
+ default = DEFAULT_TOKEN_URL
+ },
+ set_userinfo_header = {
+ type = "boolean",
+ description = "Whether to set feishu user information in request
headers",
+ default = true
+ },
+ auth_redirect_uri = {
+ type = "string",
+ description = "Redirect URI for initiating Feishu OAuth flow",
+ },
+ redirect_uri = {type = "string"},
+ timeout = {type = "integer", default = 6000},
+ ssl_verify = {type = "boolean", default = true},
+ secret = {
+ type = "string",
+ description = "Secret used for key derivation.",
+ minLength = 8,
+ maxLength = 32,
+ },
+ secret_fallbacks = {
+ type = "array",
+ items = {
+ type = "string",
+ minLength = 8,
+ maxLength = 32,
+ },
+ description = "List of secrets for alternative secrets used when
doing key rotation"
+ },
+ cookie_expires_in = {
+ type = "integer",
+ description = "Valid duration (in seconds) for the authorization
cookie."
+ .. "This value defines how long the cookie remains
valid after creation.",
+ default = 86400,
+ },
+
+ },
+ encrypt_fields = {"app_secret", "secret", "secret_fallbacks"},
+ required = {"app_id", "app_secret", "secret", "auth_redirect_uri",
"redirect_uri"},
+}
+
+local _M = {
+ version = 0.1,
+ priority = 2420,
+ name = "feishu-auth",
+ schema = schema,
+}
+
+
+function _M.check_schema(conf)
+ return core.schema.check(schema, conf)
+end
+
+
+local function fetch_access_token(conf, code)
+ local httpc = http.new()
+ httpc:set_timeout(conf.timeout)
+
+ local body = {
+ grant_type = "authorization_code",
+ client_id = conf.app_id,
+ client_secret = conf.app_secret,
+ redirect_uri = conf.auth_redirect_uri,
+ code = code,
+ }
+
+ local res, err = httpc:request_uri(conf.access_token_url, {
+ method = "POST",
+ headers = {
+ ["Content-Type"] = "application/json"
+ },
+ body = core.json.encode(body),
+ ssl_verify = conf.ssl_verify
+ })
+
+ if not res then
+ core.log.error("failed to get feishu token: ", err)
+ return nil, nil, err
+ end
+
+ core.log.debug("request feishu access token response status: ",
+ res.status)
+
+ if res.status ~= 200 then
+ core.log.warn("unexpected http response status from feishu: ",
+ res.status, ", body: ", res.body)
+ return nil, nil, "unexpected response status: " .. res.status
+ .. ", body: " .. res.body
+ end
+
+ local data, err = core.json.decode(res.body)
+ if not data then
+ core.log.error("failed to decode feishu token response: ", err)
+ return nil, nil, "failed to decode response: " .. (err or "nil")
+ end
+
+ if not data.access_token or type(data.expires_in) ~= "number" then
+ core.log.error("feishu token response missing access_token or
expires_in: ", res.body)
+ return nil, nil, "missing access_token or expires_in in response"
+ end
+
+ return data.access_token, data.expires_in, nil
+end
+
+
+local function fetch_userinfo(conf, access_token)
+ local httpc = http.new()
+ httpc:set_timeout(conf.timeout)
+
+ local res, err = httpc:request_uri(conf.userinfo_url, {
+ method = "GET",
+ headers = {
+ ["Content-Type"] = "application/json",
+ ["Authorization"] = "Bearer " .. access_token,
+ },
+ ssl_verify = conf.ssl_verify
+ })
+
+ if not res then
+ core.log.error("failed to verify feishu user: ", err)
+ return nil, err
+ end
+
+ core.log.debug("request feishu userinfo response status: ", res.status, ",
body: ", res.body)
+
+ if res.status ~= 200 then
+ core.log.error("unexpected http response status from feishu: ",
+ res.status, ", body: ", res.body)
+ return nil, "unexpected http response status: " .. res.status
+ end
+
+ local data, err = core.json.decode(res.body)
+ if not data then
+ core.log.error("failed to decode feishu userinfo response: ", err, ",
body: ", res.body)
+ return nil, "failed to decode response: " .. err
+ end
+
+ if data.code ~= 0 then
+ core.log.warn("failed to get feishu userinfo: ", res.body)
+ return nil, "unexpected error code: " .. data.code
+ .. ", errmsg: " .. (data.msg or "nil")
+ end
+
+ return data.data, nil
+end
+
+
+local function get_code(conf, ctx)
+ local code = core.request.header(ctx, conf.code_header)
+ if not code then
+ local uri_args = core.request.get_uri_args(ctx) or {}
+ code = uri_args[conf.code_query]
+ end
+
+ return code
+end
+
+
+function _M.rewrite(conf, ctx)
+ local userinfo, err
+
+ -- clear any client-supplied X-Userinfo before authentication
+ core.request.set_header(ctx, "X-Userinfo", nil)
+
+ local sess, sess_err = session.open(
+ {
+ secret = conf.secret,
+ secret_fallbacks = conf.secret_fallbacks,
+ cookie_name = "feishu_session",
+ absolute_timeout = conf.cookie_expires_in,
+ }
+ )
+ if not sess then
+ core.log.error("failed to open session: ", sess_err)
+ return 500, {message = "Failed to open session"}
+ end
+
+ local raw = sess:get("userinfo")
+ if raw then
+ userinfo, err = core.json.decode(raw)
+ if not userinfo then
+ sess:destroy()
+ core.log.error("failed to decode userinfo in session: ", err)
+ return 500, {message = "Invalid userinfo in session"}
+ end
+ else
+ local code = get_code(conf, ctx)
+ if not code then
+ core.response.set_header("Location", conf.redirect_uri)
+ return 302
+ end
+
+ local refreshed = true
+ local access_token = sess:get("access_token")
+ if access_token then
+ local expires_at = sess:get("access_token_expires_at")
+ if expires_at and ngx_time() < expires_at then
+ refreshed = false
+ else
+ sess:delete("access_token")
+ sess:delete("access_token_expires_at")
+ end
+ end
+
+ if refreshed then
+ local new_access_token, expires_in, err = fetch_access_token(conf,
code)
+ if not new_access_token then
+ core.log.warn("failed to get feishu access token: ", err)
+ return 401, {
+ message = "Invalid authorization code",
+ }
+ end
+ access_token = new_access_token
+ sess:set("access_token", access_token)
+ sess:set("access_token_expires_at", ngx_time() + expires_in - 60)
+ end
+
+ local new_userinfo, err = fetch_userinfo(conf, access_token)
+ if not new_userinfo then
+ core.log.warn("failed to get feishu userinfo: ", err)
+ sess:destroy()
+ return 401, {
+ message = "Invalid authorization code",
+ }
+ end
+ userinfo = new_userinfo
+ local raw, err = core.json.encode(userinfo)
+ if not raw then
+ core.log.error("failed to encode userinfo: ", err)
+ return 500, {message = "Invalid userinfo"}
+ end
+
+ sess:set("userinfo", raw)
+ sess:save()
+ core.log.info("verified feishu user, code: ", code,
+ ", app_id: ", conf.app_id)
+ end
+
+ if userinfo and conf.set_userinfo_header ~= false then
+ core.request.set_header(ctx, "X-Userinfo",
base64_encode(core.json.encode(userinfo)))
+ end
+ ctx.external_user = userinfo
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 3bf6a73da..6cac77e86 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -511,6 +511,7 @@ plugins: # plugin list (sorted by
priority)
- jwe-decrypt # priority: 2509
- key-auth # priority: 2500
- dingtalk-auth # priority: 2430
+ - feishu-auth # priority: 2420
- acl # priority: 2410
- consumer-restriction # priority: 2400
- attach-consumer-label # priority: 2399
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index ad930b96a..ec3635413 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -132,6 +132,7 @@
"plugins/openid-connect",
"plugins/cas-auth",
"plugins/dingtalk-auth",
+ "plugins/feishu-auth",
"plugins/hmac-auth",
"plugins/authz-casbin",
"plugins/ldap-auth",
diff --git a/docs/en/latest/plugins/feishu-auth.md
b/docs/en/latest/plugins/feishu-auth.md
new file mode 100644
index 000000000..3b1f8d6b0
--- /dev/null
+++ b/docs/en/latest/plugins/feishu-auth.md
@@ -0,0 +1,128 @@
+---
+title: feishu-auth
+keywords:
+ - Apache APISIX
+ - API Gateway
+ - Plugin
+ - Feishu Auth
+ - feishu-auth
+description: This document contains information about the Apache APISIX
feishu-auth Plugin.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/feishu-auth" />
+</head>
+
+## Description
+
+The `feishu-auth` Plugin authenticates requests using the [Feishu (Lark) OAuth
2.0](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/oauth-2.0-overview)
authorization flow. Users are redirected to the Feishu login page when
unauthenticated. After a successful login, Feishu user information is stored in
an encrypted session cookie and optionally forwarded to upstream services via
the `X-Userinfo` header.
+
+## Attributes
+
+| Name | Type | Required | Default
| Description
|
+|------------------------|----------|----------|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------|
+| `app_id` | string | True |
| Feishu application App ID.
|
+| `app_secret` | string | True |
| Feishu application App Secret.
|
+| `secret` | string | True |
| Secret used for signing the session
cookie (8–32 characters). Must remain stable across restarts. |
+| `auth_redirect_uri` | string | True |
| Redirect URI registered in the Feishu
application for the OAuth flow. |
+| `redirect_uri` | string | True |
| URI to redirect the user to when no
authorization code is present (i.e. to start the OAuth flow). |
+| `code_header` | string | False | `"X-Feishu-Code"`
| HTTP header name to extract the Feishu
authorization code from. |
+| `code_query` | string | False | `"code"`
| Query parameter name to extract the
Feishu authorization code from. |
+| `access_token_url` | string | False |
`"https://open.feishu.cn/open-apis/authen/v2/oauth/token"` | URL to
exchange the authorization code for an access token.
|
+| `userinfo_url` | string | False |
`"https://open.feishu.cn/open-apis/authen/v1/user_info"` | URL to
retrieve user information using the access token.
|
+| `set_userinfo_header` | boolean | False | `true`
| When enabled, sets the `X-Userinfo`
request header with Base64-encoded Feishu user information. |
+| `cookie_expires_in` | integer | False | `86400`
| Validity duration (in seconds) for the
session cookie. |
+| `secret_fallbacks` | array | False |
| List of fallback secrets used during key
rotation. |
+| `timeout` | integer | False | `6000`
| Timeout (in milliseconds) for HTTP
requests to Feishu endpoints. |
+| `ssl_verify` | boolean | False | `true`
| When enabled, verifies the SSL
certificate when connecting to Feishu endpoints. |
+
+:::note
+
+The fields `app_secret`, `secret`, and `secret_fallbacks` are encrypted and
stored in etcd. See [encrypted storage
fields](../plugin-develop.md#encrypted-storage-fields) for more information.
+
+:::
+
+## Enable Plugin
+
+You can enable the Plugin on a specific Route as shown below:
+
+:::note
+You can fetch the `admin_key` from `config.yaml` and save to an environment
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X
PUT -d '
+{
+ "methods": ["GET"],
+ "uri": "/api/*",
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "cli_xxxxxxxxxx",
+ "app_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ "secret": "my-session-secret",
+ "auth_redirect_uri": "https://your-domain.com/api/callback",
+ "redirect_uri": "https://your-domain.com/oauth/feishu"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
+
+## How It Works
+
+The authentication flow proceeds as follows:
+
+1. A user visits a Route protected by `feishu-auth`.
+2. If no valid session cookie exists and no authorization `code` is present,
the plugin redirects the user to `redirect_uri` with HTTP 302. Your application
should then redirect the user to the Feishu OAuth authorization page.
+3. After the user authorizes, Feishu redirects back to `auth_redirect_uri`
with an authorization `code`. The plugin extracts the code either from the
`code_query` query parameter or the `code_header` HTTP header.
+4. The plugin exchanges the code for an access token at `access_token_url`,
then fetches user information from `userinfo_url`.
+5. User information is stored in an encrypted session cookie
(`feishu_session`). Subsequent requests with a valid cookie bypass the OAuth
flow.
+6. If `set_userinfo_header` is `true`, the plugin encodes the user information
as Base64 JSON and sets it in the `X-Userinfo` request header before forwarding
to the upstream.
+
+## Delete Plugin
+
+To remove the `feishu-auth` Plugin, delete the corresponding JSON
configuration from the Plugin configuration. APISIX will automatically reload
and you do not have to restart for this to take effect.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X
PUT -d '
+{
+ "methods": ["GET"],
+ "uri": "/api/*",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index f688efe97..b2fd498c6 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -121,6 +121,7 @@
"plugins/basic-auth",
"plugins/openid-connect",
"plugins/hmac-auth",
+ "plugins/feishu-auth",
"plugins/authz-casbin",
"plugins/ldap-auth",
"plugins/opa",
diff --git a/docs/zh/latest/plugins/feishu-auth.md
b/docs/zh/latest/plugins/feishu-auth.md
new file mode 100644
index 000000000..7d8f83de9
--- /dev/null
+++ b/docs/zh/latest/plugins/feishu-auth.md
@@ -0,0 +1,125 @@
+---
+title: feishu-auth
+keywords:
+ - Apache APISIX
+ - API 网关
+ - Plugin
+ - Feishu Auth
+ - feishu-auth
+description: 本篇文档介绍了 Apache APISIX feishu-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.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/feishu-auth" />
+</head>
+
+## 描述
+
+`feishu-auth` 插件使用[飞书(Lark)OAuth
2.0](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/oauth-2.0-overview)
授权流程对请求进行认证。未认证的用户将被重定向到飞书登录页面,登录成功后,飞书用户信息将存储在加密的 session Cookie 中,并可通过
`X-Userinfo` 请求头转发给上游服务。
+
+## 属性
+
+| 名称 | 类型 | 必选项 | 默认值
| 描述
|
+|-----------------------|---------|--------|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
+| `app_id` | string | 是 |
| 飞书应用的 App ID。
|
+| `app_secret` | string | 是 |
| 飞书应用的 App Secret。
|
+| `secret` | string | 是 |
| 用于签名 session Cookie 的密钥(8-32 个字符),重启后需保持不变。
|
+| `auth_redirect_uri` | string | 是 |
| 在飞书应用中注册的 OAuth 重定向 URI。
|
+| `redirect_uri` | string | 是 |
| 当请求中不含授权码时,将用户重定向到此 URI 以发起 OAuth 授权流程。
|
+| `code_header` | string | 否 | `"X-Feishu-Code"`
| 从 HTTP 请求头中提取飞书授权码所使用的请求头名称。
|
+| `code_query` | string | 否 | `"code"`
| 从 URL 查询参数中提取飞书授权码所使用的参数名称。
|
+| `access_token_url` | string | 否 |
`"https://open.feishu.cn/open-apis/authen/v2/oauth/token"` | 使用授权码换取
access token 的接口地址。 |
+| `userinfo_url` | string | 否 |
`"https://open.feishu.cn/open-apis/authen/v1/user_info"` | 使用 access
token 获取用户信息的接口地址。 |
+| `set_userinfo_header` | boolean | 否 | `true`
| 开启后,插件将飞书用户信息以 Base64 编码的 JSON 格式设置到
`X-Userinfo` 请求头中。 |
+| `cookie_expires_in` | integer | 否 | `86400`
| session Cookie 的有效时长(秒)。
|
+| `secret_fallbacks` | array | 否 |
| 密钥轮换时使用的备用密钥列表。
|
+| `timeout` | integer | 否 | `6000`
| 请求飞书接口的超时时间(毫秒)。
|
+| `ssl_verify` | boolean | 否 | `true`
| 开启后,连接飞书接口时会验证 SSL 证书。
|
+
+注意:schema 中定义了 `encrypt_fields = {"app_secret", "secret",
"secret_fallbacks"}`,这意味着这些字段将会被加密存储在 etcd
中。具体参考[加密存储字段](../plugin-develop.md#加密存储字段)。
+
+## 启用插件
+
+以下示例展示了如何在指定路由上启用 `feishu-auth` 插件:
+
+:::note
+
+你可以这样从 `config.yaml` 中获取 `admin_key` 并存入环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X
PUT -d '
+{
+ "methods": ["GET"],
+ "uri": "/api/*",
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "cli_xxxxxxxxxx",
+ "app_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ "secret": "my-session-secret",
+ "auth_redirect_uri": "https://your-domain.com/api/callback",
+ "redirect_uri": "https://your-domain.com/oauth/feishu"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
+
+## 工作原理
+
+认证流程如下:
+
+1. 用户访问受 `feishu-auth` 插件保护的路由。
+2. 若不存在有效的 session Cookie 且请求中不含授权 `code`,插件将以 HTTP 302 重定向用户至
`redirect_uri`。你的应用随后应将用户重定向到飞书 OAuth 授权页面。
+3. 用户授权后,飞书将携带授权 `code` 重定向回 `auth_redirect_uri`。插件从 `code_query` 查询参数或
`code_header` 请求头中提取该授权码。
+4. 插件向 `access_token_url` 发起请求,使用授权码换取 access token,再从 `userinfo_url` 获取用户信息。
+5. 用户信息存储在加密的 session Cookie(`feishu_session`)中。后续携带有效 Cookie 的请求将跳过 OAuth 流程。
+6. 若 `set_userinfo_header` 为 `true`,插件将用户信息 Base64 编码后设置到 `X-Userinfo`
请求头,随请求转发至上游服务。
+
+## 删除插件
+
+如需禁用 `feishu-auth` 插件,可删除插件配置中对应的 JSON 配置。APISIX 将自动重新加载,无需重启。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X
PUT -d '
+{
+ "methods": ["GET"],
+ "uri": "/api/*",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index eecb0e897..783836567 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -88,6 +88,7 @@ jwt-auth
jwe-decrypt
key-auth
dingtalk-auth
+feishu-auth
acl
consumer-restriction
attach-consumer-label
diff --git a/t/plugin/feishu-auth.t b/t/plugin/feishu-auth.t
new file mode 100644
index 000000000..9368b9e80
--- /dev/null
+++ b/t/plugin/feishu-auth.t
@@ -0,0 +1,678 @@
+#
+# 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);
+log_level('debug');
+no_long_string();
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $extra_init_by_lua = <<_EOC_;
+ local server = require("lib.server")
+ server.token = function()
+ local json = require("cjson")
+ local headers = ngx.req.get_headers()
+ ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ",
json.encode(headers))
+
+ ngx.req.read_body()
+ local data = ngx.req.get_body_data()
+ ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
+
+ local payload = json.decode(data)
+
+ if payload.code ~= "passed" then
+ ngx.status = 400
+ ngx.say([[{"code": 20051, "error_description": "Unauthorized"}]])
+ return
+ end
+
+ ngx.log(ngx.INFO, ngx.var.request_uri, " payload: ", data)
+
+ local resp_payload = [[
+{
+ "code": 0,
+ "expires_in": 7200,
+ "access_token": "85b8b7665c4c3bc5bd91d8e6cb6594b7",
+ "token_type": "Bearer"
+}
+ ]]
+
+ ngx.say(resp_payload)
+ end
+
+ server.userinfo = function()
+ local json = require("cjson")
+ local headers = ngx.req.get_headers()
+ ngx.log(ngx.INFO, ngx.var.request_uri, " receive headers: ",
json.encode(headers))
+
+ local resp_payload = [[
+{
+ "code": 0,
+ "data": {
+ "avatar_big":
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4be",
+ "avatar_middle":
"https://s1-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4",
+ "avatar_thumb":
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba",
+ "avatar_url":
"https://s3-imfile.feishucdn.com/static-resource/v1/v2_d8ffef5f-bb1b-4ba0-bf05-1487b4beba4g",
+ "en_name": "jack",
+ "name": "jack",
+ "open_id": "ou_8fc70d9ea27111749a71eb",
+ "tenant_key": "1224d18e8d",
+ "union_id": "on_c249ec29c9d6"
+ },
+ "msg": "success"
+}
+ ]]
+
+ ngx.say(resp_payload)
+ end
+
+ server.hello_echo = function()
+ -- echo back the X-Userinfo header value so tests can assert it was
cleared
+ ngx.say(ngx.req.get_headers()["x-userinfo"] or "none")
+ end
+_EOC_
+
+ $block->set_value("extra_init_by_lua", $extra_init_by_lua);
+
+
+ if (!$block->request) {
+ if (!$block->stream_request) {
+ $block->set_value("request", "GET /t");
+ }
+ }
+
+ if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+ $block->set_value("no_error_log", "[error]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: enable feishu-auth plugin
+--- 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,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "plugins":{
+ "feishu-auth":{
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "my-secret",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "cookie_expires_in": 2,
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+
+ if code <= 201 then
+ ngx.status = 200
+ end
+
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: missing code
+--- request
+GET /hello
+--- error_code: 302
+--- response_headers
+Location: /echo
+
+
+
+=== TEST 3: invalid code
+--- request
+GET /hello?code=invalid
+--- error_code: 401
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 4: valid code
+--- request
+GET /hello?code=passed
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 5: X-Feishu-Code with invalid code
+--- request
+GET /hello
+--- more_headers
+X-Feishu-Code: invalid
+--- error_code: 401
+--- response_body
+{"message":"Invalid authorization code"}
+
+
+
+=== TEST 6: X-Feishu-Code header
+--- request
+GET /hello
+--- more_headers
+X-Feishu-Code: passed
+--- error_code: 200
+--- response_body
+hello world
+
+
+
+=== TEST 7: check cookie
+--- 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 .. "/hello"
+ local res, err = httpc:request_uri(uri, {
+ query = {
+ code = "passed",
+ },
+ method = "GET",
+ })
+ assert(res, "request failed: " .. (err or "unknown error"))
+ assert(res.status == 200, "unexpected res status: " .. res.status)
+
+ local cookie = res.headers["Set-Cookie"]
+ assert(cookie, "missing Set-Cookie header")
+
+ -- request with cookie
+ local res2, err = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie,
+ },
+ })
+ assert(res2, "request failed: " .. (err or "unknown error"))
+ assert(res2.status == 200, "unexpected res2 status: " ..
res2.status)
+
+ --- request without cookie
+ local res3, err = httpc:request_uri(uri, {
+ method = "GET",
+ })
+ assert(res3, "request failed: " .. (err or "unknown error"))
+ assert(res3.status == 302, "unexpected res3 status: " ..
res3.status)
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 8: cookie expire
+--- 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 .. "/hello"
+ local res, err = httpc:request_uri(uri, {
+ query = {
+ code = "passed",
+ },
+ method = "GET",
+ })
+ assert(res, "request failed: " .. (err or "unknown error"))
+ assert(res.status == 200, "unexpected res status: " .. res.status)
+
+ local cookie = res.headers["Set-Cookie"]
+ assert(cookie, "missing Set-Cookie header")
+
+ -- request with cookie
+ local res2, err = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie,
+ },
+ })
+ assert(res2, "request failed: " .. (err or "unknown error"))
+ assert(res2.status == 200, "unexpected res2 status: " ..
res2.status)
+
+ ngx.sleep(3)
+
+ --- request without cookie
+ local res3, err = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie,
+ },
+ })
+ assert(res3, "request failed: " .. (err or "unknown error"))
+ assert(res3.status == 302, "unexpected res3 status: " ..
res3.status)
+
+ ngx.say("passed")
+ }
+ }
+--- timeout: 5
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 9: specify header and query and redirect_uri
+--- 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,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "plugins":{
+ "feishu-auth":{
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "my-secret",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "code_query": "custom_code",
+ "code_header": "Custom-feishu-Code",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+
+ if code <= 201 then
+ ngx.status = 200
+ end
+
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 10: specify query
+--- pipelined_requests eval
+["GET /hello?code=passed", "GET /hello?custom_code=passed"]
+--- error_code eval
+[302, 200]
+
+
+
+=== TEST 11: specify header
+--- pipelined_requests eval
+["GET /hello", "GET /hello"]
+--- more_headers eval
+[
+"X-Feishu-Code: passed",
+"Custom-Feishu-Code: passed"
+]
+--- error_code eval
+[302, 200]
+
+
+
+=== TEST 12: secret_fallbacks allows session created with old secret after key
rotation
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local httpc = http.new()
+ local t = require("lib.test_admin").test
+
+ -- step 1: create route with secret-v1, no fallbacks
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "secret-v1",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+ assert(code <= 201, "setup v1 failed: " .. tostring(code))
+
+ -- step 2: authenticate with secret-v1 and capture session cookie
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+ local res, err = httpc:request_uri(uri, {
+ method = "GET",
+ query = {code = "passed"},
+ })
+ assert(res, err)
+ assert(res.status == 200, "expected 200, got " .. res.status)
+ local old_cookie = res.headers["Set-Cookie"]
+ assert(old_cookie, "expected Set-Cookie from v1")
+
+ -- step 3: rotate to secret-v2 with secret-v1 in secret_fallbacks
+ local code2, body2 = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "secret-v2",
+ "secret_fallbacks": ["secret-v1"],
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+ assert(code2 <= 201, "setup v2 failed: " .. tostring(code2))
+
+ -- step 4: old cookie should still work via fallback
+ local res2, err2 = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {["Cookie"] = old_cookie},
+ })
+ assert(res2, err2)
+ assert(res2.status == 200,
+ "old cookie should be accepted via fallback, got " ..
res2.status)
+
+ -- step 5: remove fallbacks; old cookie should no longer work
+ local code3, body3 = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "secret-v2",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+ assert(code3 <= 201, "setup v2-no-fallback failed: " ..
tostring(code3))
+
+ local res3, err3 = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {["Cookie"] = old_cookie},
+ })
+ assert(res3, err3)
+ assert(res3.status == 302,
+ "old cookie should be rejected without fallback, got " ..
res3.status)
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 13: forged X-Userinfo header does not bypass authentication
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local httpc = http.new()
+ local t = require("lib.test_admin").test
+
+ -- restore route to a simple config
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "my-secret-xyz",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+ assert(code <= 201, "setup failed: " .. tostring(code))
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+
+ -- forged X-Userinfo without a session cookie must be rejected
+ local res1, err1 = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["X-Userinfo"] =
ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
+ },
+ })
+ assert(res1, err1)
+ assert(res1.status == 302,
+ "forged X-Userinfo without cookie should be rejected, got " ..
res1.status)
+
+ -- obtain a legitimate session cookie
+ local res2, err2 = httpc:request_uri(uri, {
+ method = "GET",
+ query = {code = "passed"},
+ })
+ assert(res2, err2)
+ assert(res2.status == 200, "expected 200 on auth, got " ..
res2.status)
+ local cookie = res2.headers["Set-Cookie"]
+ assert(cookie, "expected Set-Cookie after auth")
+
+ -- valid cookie + forged X-Userinfo: request succeeds only due to
the cookie
+ local res3, err3 = httpc:request_uri(uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie,
+ ["X-Userinfo"] =
ngx.encode_base64('{"open_id":"forged","name":"hacker"}'),
+ },
+ })
+ assert(res3, err3)
+ assert(res3.status == 200,
+ "valid cookie should be accepted regardless of forged header,
got " .. res3.status)
+
+ -- create a route with set_userinfo_header=false to verify the
forged header
+ -- is cleared and not forwarded to upstream
+ local code2, body2 = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "my-secret-xyz",
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo",
+ "set_userinfo_header": false
+ }
+ },
+ "uri": "/hello-echo"
+ }]]
+ )
+ assert(code2 <= 201, "setup echo route failed: " ..
tostring(code2))
+
+ local echo_uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/hello-echo"
+ local forged =
ngx.encode_base64('{"open_id":"forged","name":"hacker"}')
+
+ -- with set_userinfo_header=false, upstream must not receive any
X-Userinfo
+ local res4, err4 = httpc:request_uri(echo_uri, {
+ method = "GET",
+ headers = {
+ ["Cookie"] = cookie,
+ ["X-Userinfo"] = forged,
+ },
+ })
+ assert(res4, err4)
+ assert(res4.status == 200,
+ "expected 200 on echo route, got " .. res4.status)
+ assert(res4.body == "none\n",
+ "forged X-Userinfo must not reach upstream, got: " ..
(res4.body or "nil"))
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 14: secret_fallbacks values are encrypted in etcd
+--- yaml_config
+apisix:
+ data_encryption:
+ enable_encrypt_fields: true
+ keyring:
+ - edd1c9f0985e76a2
+--- config
+ location /t {
+ content_by_lua_block {
+ local json = require("toolkit.json")
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "plugins": {
+ "feishu-auth": {
+ "app_id": "123",
+ "app_secret": "456",
+ "secret": "my-secret-key-v2",
+ "secret_fallbacks": ["my-secret-key-v1"],
+ "auth_redirect_uri": "https://example.com",
+ "access_token_url": "http://127.0.0.1:1980/token",
+ "userinfo_url": "http://127.0.0.1:1980/userinfo",
+ "redirect_uri": "/echo"
+ }
+ },
+ "uri": "/hello"
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ ngx.sleep(0.1)
+
+ -- Admin API should return decrypted values
+ local code2, _, res2 = t('/apisix/admin/routes/1', ngx.HTTP_GET)
+ assert(code2 == 200, "admin get route failed: " .. tostring(code2))
+ res2 = json.decode(res2)
+ local conf = res2.value.plugins["feishu-auth"]
+ ngx.say("admin secret_fallbacks[1]: ", conf.secret_fallbacks[1])
+
+ -- etcd should store encrypted values
+ local etcd = require("apisix.core.etcd")
+ local etcd_res = assert(etcd.get('/routes/1'))
+ local etcd_conf = etcd_res.body.node.value.plugins["feishu-auth"]
+ assert(etcd_conf.secret_fallbacks and
etcd_conf.secret_fallbacks[1],
+ "expected secret_fallbacks[1] in etcd payload")
+ ngx.say("etcd secret_fallbacks[1] encrypted: ",
+ etcd_conf.secret_fallbacks[1] ~= "my-secret-key-v1")
+ }
+ }
+--- response_body
+admin secret_fallbacks[1]: my-secret-key-v1
+etcd secret_fallbacks[1] encrypted: true