Skip to content

Commit f26dd4e

Browse files
fix(feishu-auth, dingtalk-auth): bind the authorization code to the session that started the login
Both plugins emitted the login redirect and consumed the callback without a state parameter, so a code from the query string was accepted regardless of which session it arrived on. Generate a random state before redirecting, carry it on the redirect URL, and require it back on codes taken from the query string. Codes taken from the configured header are exempt, since those requests come from non-browser clients. Also replaces sess:delete(), which does not exist in lua-resty-session 4.x and crashed the feishu-auth token refresh path.
1 parent 39b9e43 commit f26dd4e

8 files changed

Lines changed: 419 additions & 101 deletions

File tree

apisix/plugins/dingtalk-auth.lua

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@
1717
local core = require("apisix.core")
1818
local http = require("resty.http")
1919
local session = require("resty.session")
20+
local resty_random = require("resty.random")
21+
local resty_string = require("resty.string")
2022

2123
local base64_encode = ngx.encode_base64
2224

25+
local STATE_BYTES = 16
26+
2327
-- the access token from dingtalk has a TTL of 7200 seconds,
2428
-- we set the cache TTL to 7000 seconds to avoid edge cases of token expiration during use.
2529
local access_token_cache = core.lrucache.new({
@@ -201,14 +205,46 @@ local function fetch_userinfo(conf, access_token, code)
201205
end
202206

203207

208+
local function session_opts(conf)
209+
return {
210+
secret = conf.secret,
211+
secret_fallbacks = conf.secret_fallbacks,
212+
cookie_name = "dingtalk_session",
213+
absolute_timeout = conf.cookie_expires_in,
214+
}
215+
end
216+
217+
218+
-- returns the code and whether it came from the request header
204219
local function get_code(conf, ctx)
205220
local code = core.request.header(ctx, conf.code_header)
206-
if not code then
207-
local uri_args = core.request.get_uri_args(ctx) or {}
208-
code = uri_args[conf.code_query]
221+
if code then
222+
return code, true
223+
end
224+
225+
local uri_args = core.request.get_uri_args(ctx) or {}
226+
return uri_args[conf.code_query], false
227+
end
228+
229+
230+
-- bind a fresh state to the session and carry it along to the login page,
231+
-- so the code that comes back can be tied to the browser that started the flow
232+
local function redirect_to_login(conf, opts)
233+
local bytes = resty_random.bytes(STATE_BYTES, true)
234+
or resty_random.bytes(STATE_BYTES)
235+
local state = resty_string.to_hex(bytes)
236+
237+
local sess = session.start(opts)
238+
sess:set("state", state)
239+
local ok, err = sess:save()
240+
if not ok then
241+
core.log.error("failed to save session: ", err)
242+
return 500, {message = "Failed to save session"}
209243
end
210244

211-
return code
245+
local sep = core.string.find(conf.redirect_uri, "?") and "&" or "?"
246+
core.response.set_header("Location", conf.redirect_uri .. sep .. "state=" .. state)
247+
return 302
212248
end
213249

214250

@@ -218,14 +254,8 @@ function _M.rewrite(conf, ctx)
218254
-- clear any client-supplied X-Userinfo before authentication
219255
core.request.set_header(ctx, "X-Userinfo", nil)
220256

221-
local sess, sess_err = session.open(
222-
{
223-
secret = conf.secret,
224-
secret_fallbacks = conf.secret_fallbacks,
225-
cookie_name = "dingtalk_session",
226-
absolute_timeout = conf.cookie_expires_in,
227-
}
228-
)
257+
local opts = session_opts(conf)
258+
local sess, sess_err = session.open(opts)
229259
if not sess then
230260
core.log.error("failed to open session: ", sess_err)
231261
return 500, {message = "Failed to open session"}
@@ -237,14 +267,26 @@ function _M.rewrite(conf, ctx)
237267
if not userinfo then
238268
sess:destroy()
239269
core.log.error("failed to decode userinfo in session: ", err)
240-
core.response.set_header("Location", conf.redirect_uri)
241-
return 302
270+
return redirect_to_login(conf, opts)
242271
end
243272
else
244-
local code = get_code(conf, ctx)
273+
local code, from_header = get_code(conf, ctx)
245274
if not code then
246-
core.response.set_header("Location", conf.redirect_uri)
247-
return 302
275+
return redirect_to_login(conf, opts)
276+
end
277+
278+
-- a code in the query string comes back from the login redirect, so it must
279+
-- carry the state bound to this session. a code in the header comes from a
280+
-- non-browser client, which cannot be driven cross-site.
281+
if not from_header then
282+
local uri_args = core.request.get_uri_args(ctx) or {}
283+
local state = sess:get("state")
284+
if not state or uri_args.state ~= state then
285+
sess:destroy()
286+
core.log.warn("state does not match the one bound to the session")
287+
return 401, {message = "Invalid state"}
288+
end
289+
sess:set("state", nil)
248290
end
249291

250292
local key = core.table.concat({

apisix/plugins/feishu-auth.lua

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
local core = require("apisix.core")
1818
local http = require("resty.http")
1919
local session = require("resty.session")
20+
local resty_random = require("resty.random")
21+
local resty_string = require("resty.string")
2022

2123
local base64_encode = ngx.encode_base64
2224
local ngx_time = ngx.time
2325
local type = type
2426

27+
local STATE_BYTES = 16
28+
2529
local DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"
2630
local DEFAULT_USERINFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
2731

@@ -193,14 +197,46 @@ local function fetch_userinfo(conf, access_token)
193197
end
194198

195199

200+
local function session_opts(conf)
201+
return {
202+
secret = conf.secret,
203+
secret_fallbacks = conf.secret_fallbacks,
204+
cookie_name = "feishu_session",
205+
absolute_timeout = conf.cookie_expires_in,
206+
}
207+
end
208+
209+
210+
-- returns the code and whether it came from the request header
196211
local function get_code(conf, ctx)
197212
local code = core.request.header(ctx, conf.code_header)
198-
if not code then
199-
local uri_args = core.request.get_uri_args(ctx) or {}
200-
code = uri_args[conf.code_query]
213+
if code then
214+
return code, true
215+
end
216+
217+
local uri_args = core.request.get_uri_args(ctx) or {}
218+
return uri_args[conf.code_query], false
219+
end
220+
221+
222+
-- bind a fresh state to the session and carry it along to the login page,
223+
-- so the code that comes back can be tied to the browser that started the flow
224+
local function redirect_to_login(conf, opts)
225+
local bytes = resty_random.bytes(STATE_BYTES, true)
226+
or resty_random.bytes(STATE_BYTES)
227+
local state = resty_string.to_hex(bytes)
228+
229+
local sess = session.start(opts)
230+
sess:set("state", state)
231+
local ok, err = sess:save()
232+
if not ok then
233+
core.log.error("failed to save session: ", err)
234+
return 500, {message = "Failed to save session"}
201235
end
202236

203-
return code
237+
local sep = core.string.find(conf.redirect_uri, "?") and "&" or "?"
238+
core.response.set_header("Location", conf.redirect_uri .. sep .. "state=" .. state)
239+
return 302
204240
end
205241

206242

@@ -210,14 +246,8 @@ function _M.rewrite(conf, ctx)
210246
-- clear any client-supplied X-Userinfo before authentication
211247
core.request.set_header(ctx, "X-Userinfo", nil)
212248

213-
local sess, sess_err = session.open(
214-
{
215-
secret = conf.secret,
216-
secret_fallbacks = conf.secret_fallbacks,
217-
cookie_name = "feishu_session",
218-
absolute_timeout = conf.cookie_expires_in,
219-
}
220-
)
249+
local opts = session_opts(conf)
250+
local sess, sess_err = session.open(opts)
221251
if not sess then
222252
core.log.error("failed to open session: ", sess_err)
223253
return 500, {message = "Failed to open session"}
@@ -232,10 +262,23 @@ function _M.rewrite(conf, ctx)
232262
return 500, {message = "Invalid userinfo in session"}
233263
end
234264
else
235-
local code = get_code(conf, ctx)
265+
local code, from_header = get_code(conf, ctx)
236266
if not code then
237-
core.response.set_header("Location", conf.redirect_uri)
238-
return 302
267+
return redirect_to_login(conf, opts)
268+
end
269+
270+
-- a code in the query string comes back from the login redirect, so it must
271+
-- carry the state bound to this session. a code in the header comes from a
272+
-- non-browser client, which cannot be driven cross-site.
273+
if not from_header then
274+
local uri_args = core.request.get_uri_args(ctx) or {}
275+
local state = sess:get("state")
276+
if not state or uri_args.state ~= state then
277+
sess:destroy()
278+
core.log.warn("state does not match the one bound to the session")
279+
return 401, {message = "Invalid state"}
280+
end
281+
sess:set("state", nil)
239282
end
240283

241284
local refreshed = true
@@ -245,8 +288,8 @@ function _M.rewrite(conf, ctx)
245288
if expires_at and ngx_time() < expires_at then
246289
refreshed = false
247290
else
248-
sess:delete("access_token")
249-
sess:delete("access_token_expires_at")
291+
sess:set("access_token", nil)
292+
sess:set("access_token_expires_at", nil)
250293
end
251294
end
252295

docs/en/latest/plugins/dingtalk-auth.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 \
119119

120120
Once you have enabled the Plugin, incoming requests to the Route are processed as follows:
121121

122-
1. **No session and no code**: The user is redirected to `redirect_uri` (typically a DingTalk OAuth login page) with a `302` response.
123-
2. **Authorization code present** (in the `code` query parameter or `X-DingTalk-Code` header): The Plugin exchanges the code for an access token via `access_token_url`, then retrieves user information from `userinfo_url`. On success, the user information is stored in an encrypted cookie session and the original request proceeds.
122+
1. **No session and no code**: The Plugin generates a random `state`, stores it in the session cookie, and redirects the user to `redirect_uri` (typically a DingTalk OAuth login page) with a `302` response, appending `state` to the query string. Pass `state` through to DingTalk so that it is returned on the callback.
123+
2. **Authorization code present** (in the `code` query parameter or `X-DingTalk-Code` header): A code taken from the query parameter must arrive with the `state` bound to the session, otherwise the Plugin responds with `401`. This ties the code to the browser that started the flow. A code taken from the `X-DingTalk-Code` header is exempt, since such requests come from non-browser clients. The Plugin then exchanges the code for an access token via `access_token_url`, then retrieves user information from `userinfo_url`. On success, the user information is stored in an encrypted cookie session and the original request proceeds.
124124
3. **Valid session cookie**: Subsequent requests carrying the session cookie bypass DingTalk API calls entirely and proceed directly to the upstream.
125125

126126
When `set_userinfo_header` is `true` (the default), the upstream receives the DingTalk user information in the `X-Userinfo` header as a Base64-encoded JSON object.

docs/en/latest/plugins/feishu-auth.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,12 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X P
102102
The authentication flow proceeds as follows:
103103

104104
1. A user visits a Route protected by `feishu-auth`.
105-
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.
105+
2. If no valid session cookie exists and no authorization `code` is present, the plugin generates a random `state`, stores it in the session cookie, and redirects the user to `redirect_uri` with HTTP 302, appending `state` to the query string. Your application should then redirect the user to the Feishu OAuth authorization page, passing `state` through so that Feishu returns it on the callback.
106106
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.
107-
4. The plugin exchanges the code for an access token at `access_token_url`, then fetches user information from `userinfo_url`.
108-
5. User information is stored in an encrypted session cookie (`feishu_session`). Subsequent requests with a valid cookie bypass the OAuth flow.
109-
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.
107+
4. A code taken from the query string must arrive with the `state` bound to the session, otherwise the plugin responds with HTTP 401. This ties the code to the browser that started the flow. A code taken from the `code_header` HTTP header is exempt, since such requests come from non-browser clients.
108+
5. The plugin exchanges the code for an access token at `access_token_url`, then fetches user information from `userinfo_url`.
109+
6. User information is stored in an encrypted session cookie (`feishu_session`). Subsequent requests with a valid cookie bypass the OAuth flow.
110+
7. 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.
110111

111112
## Delete Plugin
112113

docs/zh/latest/plugins/feishu-auth.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,12 @@ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X P
9999
认证流程如下:
100100

101101
1. 用户访问受 `feishu-auth` 插件保护的路由。
102-
2. 若不存在有效的 session Cookie 且请求中不含授权 `code`插件将以 HTTP 302 重定向用户至 `redirect_uri`。你的应用随后应将用户重定向到飞书 OAuth 授权页面。
102+
2. 若不存在有效的 session Cookie 且请求中不含授权 `code`插件将生成随机 `state` 并存入 session Cookie,然后以 HTTP 302 重定向用户至 `redirect_uri`,并在其查询字符串中附加 `state`。你的应用随后应将用户重定向到飞书 OAuth 授权页面,并透传 `state`,以便飞书在回调时将其返回
103103
3. 用户授权后,飞书将携带授权 `code` 重定向回 `auth_redirect_uri`。插件从 `code_query` 查询参数或 `code_header` 请求头中提取该授权码。
104-
4. 插件向 `access_token_url` 发起请求,使用授权码换取 access token,再从 `userinfo_url` 获取用户信息。
105-
5. 用户信息存储在加密的 session Cookie(`feishu_session`)中。后续携带有效 Cookie 的请求将跳过 OAuth 流程。
106-
6.`set_userinfo_header``true`,插件将用户信息 Base64 编码后设置到 `X-Userinfo` 请求头,随请求转发至上游服务。
104+
4. 从查询参数中获取的授权码必须携带与当前 session 绑定的 `state`,否则插件返回 HTTP 401。该校验将授权码与发起流程的浏览器绑定。从 `code_header` 请求头中获取的授权码不做此校验,因为此类请求来自非浏览器客户端。
105+
5. 插件向 `access_token_url` 发起请求,使用授权码换取 access token,再从 `userinfo_url` 获取用户信息。
106+
6. 用户信息存储在加密的 session Cookie(`feishu_session`)中。后续携带有效 Cookie 的请求将跳过 OAuth 流程。
107+
7.`set_userinfo_header``true`,插件将用户信息 Base64 编码后设置到 `X-Userinfo` 请求头,随请求转发至上游服务。
107108

108109
## 删除插件
109110

t/lib/oauth_login.lua

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
local http = require("resty.http")
18+
local str_match = string.match
19+
20+
local _M = {}
21+
22+
23+
-- follow the redirect to the login page and return the session cookie
24+
-- together with the state bound to it
25+
function _M.begin(port, path)
26+
local httpc = http.new()
27+
local uri = "http://127.0.0.1:" .. port .. path
28+
29+
local res, err = httpc:request_uri(uri, {method = "GET"})
30+
if not res then
31+
return nil, nil, err
32+
end
33+
if res.status ~= 302 then
34+
return nil, nil, "expected 302 to the login page, got " .. res.status
35+
end
36+
37+
local cookie = res.headers["Set-Cookie"]
38+
local state = str_match(res.headers["Location"] or "", "state=([0-9a-f]+)")
39+
if not cookie or not state then
40+
return nil, nil, "redirect did not carry a session cookie and a state"
41+
end
42+
43+
return cookie, state
44+
end
45+
46+
47+
-- drive a full login: pick up the state, then come back with the code
48+
function _M.login(port, path, code, code_query)
49+
local cookie, state, err = _M.begin(port, path)
50+
if not cookie then
51+
return nil, err
52+
end
53+
54+
local query = {state = state}
55+
query[code_query or "code"] = code
56+
57+
return http.new():request_uri("http://127.0.0.1:" .. port .. path, {
58+
method = "GET",
59+
query = query,
60+
headers = {["Cookie"] = cookie},
61+
})
62+
end
63+
64+
65+
return _M

0 commit comments

Comments
 (0)