11use jsonwebtoken:: { Algorithm , DecodingKey , Validation , decode} ;
2- use serde:: de:: DeserializeOwned ;
32use serde:: { Deserialize , Serialize } ;
43use sha2:: { Digest , Sha256 } ;
54use std:: collections:: HashMap ;
@@ -70,7 +69,6 @@ const DEFAULT_APP_REQUEST_TIMEOUT_SECS: u64 = 10;
7069const DEFAULT_ADDRESS_CACHE_TTL_SECS : u64 = 30 ;
7170const MAX_ADDRESS_CACHE_ENTRIES : usize = 2_000 ;
7271const MIN_PROXY_SECRET_BYTES : usize = 32 ;
73- const SANDBOX_TICKET_HEADER : & str = "X-Sandbox-Ticket" ;
7472const SANDBOX_PREVIEW_SESSION_HEADER : & str = "X-Sandbox-Preview-Session" ;
7573
7674#[ derive( Clone ) ]
@@ -108,30 +106,23 @@ pub fn get_proxy_secret() -> &'static str {
108106 } )
109107}
110108
111- fn decode_jwt_ticket < T : DeserializeOwned > ( ticket : & str ) -> Result < T , String > {
109+ /// 在代理层边缘进行本地 JWT 验签防刷,直接在第一道闸口过滤非法请求
110+ pub fn verify_jwt_ticket ( ticket : & str ) -> Result < Claims , String > {
112111 let secret = get_proxy_secret ( ) ;
113112 let decoding_key = DecodingKey :: from_secret ( secret. as_bytes ( ) ) ;
114113
115114 // JWT 默认使用 HS256 算法,配合 5 秒的时钟容差 (leeway)
116115 let mut validation = Validation :: new ( Algorithm :: HS256 ) ;
117116 validation. leeway = 5 ;
118117
119- let token_data = decode :: < T > ( ticket, & decoding_key, & validation)
118+ let token_data = decode :: < Claims > ( ticket, & decoding_key, & validation)
120119 . map_err ( |err| format ! ( "JWT signature validation failed: {}" , err) ) ?;
121120
122121 Ok ( token_data. claims )
123122}
124123
125- /// 在代理层边缘校验既有 WebSocket ticket,保留上游 teamId claims 契约。
126- pub fn verify_jwt_ticket ( ticket : & str ) -> Result < Claims , String > {
127- decode_jwt_ticket ( ticket)
128- }
129-
130- /// 反向请求 NextJS 主站验证凭证,并置换出真实的沙盒物理端点寻址信息。
131- async fn request_sandbox_address (
132- credential : & str ,
133- credential_header : & ' static str ,
134- ) -> Result < SandboxAddress , String > {
124+ /// 反向请求 NextJS 主站进行 Ticket 验证,并置换出真实的沙盒物理端点寻址信息 (高安全有状态地址置换方案)
125+ pub async fn resolve_sandbox_address ( ticket : & str ) -> Result < SandboxAddress , String > {
135126 // 1. 读取主站内网 API 基准地址 (默认 http://localhost:3000)
136127 let app_url =
137128 env:: var ( "FASTGPT_APP_URL" ) . unwrap_or_else ( |_| "http://localhost:3000" . to_string ( ) ) ;
@@ -140,17 +131,16 @@ async fn request_sandbox_address(
140131 let request_url = format ! ( "{}/api/core/ai/sandbox/verifyTicket" , clean_app_url) ;
141132
142133 debug ! (
143- "[Auth] Back-channel requesting App to resolve credential . Target: {}" ,
134+ "[Auth] Back-channel requesting App to resolve ticket . Target: {}" ,
144135 request_url
145136 ) ;
146137
147- // 2. 凭证放在内网 header,避免主站 access log 记录 bearer token。
138+ // 2. 发起内网 HTTP GET 请求 (复用全局共享的 TCP 连接池,自动处理 Keep-Alive 与 URL 编码)
148139 let client = get_http_client ( ) ;
149- let request = client
150- . get ( & request_url)
151- . header ( credential_header, credential)
152- // 3. 共享密钥只用于 proxy 到主站的反向通道认证。
153- . header ( "X-Proxy-Token" , get_proxy_secret ( ) ) ;
140+ let mut request = client. get ( & request_url) . query ( & [ ( "ticket" , ticket) ] ) ;
141+
142+ // 3. 安全二次加固:必须携带正确的 AGENT_SANDBOX_PROXY_SECRET,在 Header 中注入安全防刷握手 Token
143+ request = request. header ( "X-Proxy-Token" , get_proxy_secret ( ) ) ;
154144
155145 let response = request
156146 . send ( )
@@ -188,18 +178,62 @@ async fn request_sandbox_address(
188178
189179 let address = app_res. data ;
190180
191- debug ! ( "[Auth] Credential resolved successfully." ) ;
181+ debug ! ( "[Auth] Ticket resolved successfully." ) ;
192182 Ok ( address)
193183}
194184
195- /// 验证既有 WebSocket JWT ticket 并解析 sandbox 地址。
196- pub async fn resolve_sandbox_address ( ticket : & str ) -> Result < SandboxAddress , String > {
197- request_sandbox_address ( ticket, SANDBOX_TICKET_HEADER ) . await
198- }
199-
200185/// 验证有状态 Preview session 并解析只读 HTTP sandbox 地址。
201186async fn resolve_preview_sandbox_address ( session_id : & str ) -> Result < SandboxAddress , String > {
202- request_sandbox_address ( session_id, SANDBOX_PREVIEW_SESSION_HEADER ) . await
187+ let app_url =
188+ env:: var ( "FASTGPT_APP_URL" ) . unwrap_or_else ( |_| "http://localhost:3000" . to_string ( ) ) ;
189+ let request_url = format ! (
190+ "{}/api/core/ai/sandbox/verifyTicket" ,
191+ app_url. trim_end_matches( '/' )
192+ ) ;
193+
194+ debug ! (
195+ "[Auth] Back-channel requesting App to resolve preview session. Target: {}" ,
196+ request_url
197+ ) ;
198+
199+ let response = get_http_client ( )
200+ . get ( & request_url)
201+ . header ( SANDBOX_PREVIEW_SESSION_HEADER , session_id)
202+ . header ( "X-Proxy-Token" , get_proxy_secret ( ) )
203+ . send ( )
204+ . await
205+ . map_err ( |err| format ! ( "HTTP request to App verifyTicket failed: {}" , err) ) ?;
206+
207+ let status = response. status ( ) ;
208+ if !status. is_success ( ) {
209+ let err_text = response
210+ . text ( )
211+ . await
212+ . unwrap_or_else ( |_| "Unknown error" . to_string ( ) ) ;
213+ error ! (
214+ "[Auth] Preview session validation rejected by App (Status {}): {}" ,
215+ status, err_text
216+ ) ;
217+ return Err ( format ! ( "Preview session rejected by App: {}" , err_text) ) ;
218+ }
219+
220+ let app_res = response
221+ . json :: < AppResponse < SandboxAddress > > ( )
222+ . await
223+ . map_err ( |err| format ! ( "Failed to parse response JSON from App: {}" , err) ) ?;
224+
225+ if app_res. code != 200 {
226+ return Err ( format ! (
227+ "App returned error code {}: {:?}" ,
228+ app_res. code,
229+ app_res
230+ . status_text
231+ . unwrap_or_else( || "Unknown error" . to_string( ) )
232+ ) ) ;
233+ }
234+
235+ debug ! ( "[Auth] Preview session resolved successfully." ) ;
236+ Ok ( app_res. data )
203237}
204238
205239fn get_address_cache_ttl ( ) -> Duration {
@@ -211,8 +245,8 @@ fn get_address_cache_ttl() -> Duration {
211245 Duration :: from_secs ( seconds)
212246}
213247
214- fn get_ticket_cache_key ( ticket : & str ) -> [ u8 ; 32 ] {
215- Sha256 :: digest ( ticket . as_bytes ( ) ) . into ( )
248+ fn get_preview_session_cache_key ( session_id : & str ) -> [ u8 ; 32 ] {
249+ Sha256 :: digest ( session_id . as_bytes ( ) ) . into ( )
216250}
217251
218252/**
@@ -221,9 +255,11 @@ fn get_ticket_cache_key(ticket: &str) -> [u8; 32] {
221255 * Session validity is checked by FastGPT on cache misses. This cache avoids repeating the
222256 * back-channel lookup and sandbox password read for each resource in one HTML page.
223257 */
224- pub async fn resolve_cached_sandbox_address ( session_id : & str ) -> Result < SandboxAddress , String > {
258+ pub async fn resolve_cached_preview_sandbox_address (
259+ session_id : & str ,
260+ ) -> Result < SandboxAddress , String > {
225261 let now = Instant :: now ( ) ;
226- let cache_key = get_ticket_cache_key ( session_id) ;
262+ let cache_key = get_preview_session_cache_key ( session_id) ;
227263 if let Some ( cached) = SANDBOX_ADDRESS_CACHE . read ( ) . await . get ( & cache_key)
228264 && cached. expires_at > now
229265 {
@@ -253,11 +289,11 @@ pub async fn resolve_cached_sandbox_address(session_id: &str) -> Result<SandboxA
253289 Ok ( address)
254290}
255291
256- pub async fn invalidate_cached_sandbox_address ( session_id : & str ) {
292+ pub async fn invalidate_cached_preview_sandbox_address ( session_id : & str ) {
257293 SANDBOX_ADDRESS_CACHE
258294 . write ( )
259295 . await
260- . remove ( & get_ticket_cache_key ( session_id) ) ;
296+ . remove ( & get_preview_session_cache_key ( session_id) ) ;
261297}
262298
263299#[ cfg( test) ]
@@ -379,14 +415,14 @@ mod tests {
379415 }
380416
381417 #[ test]
382- fn test_ticket_cache_key_is_stable_without_retaining_token ( ) {
418+ fn test_preview_session_cache_key_is_stable_without_retaining_credential ( ) {
383419 assert_eq ! (
384- get_ticket_cache_key ( "ticket -a") ,
385- get_ticket_cache_key ( "ticket -a")
420+ get_preview_session_cache_key ( "session -a") ,
421+ get_preview_session_cache_key ( "session -a")
386422 ) ;
387423 assert_ne ! (
388- get_ticket_cache_key ( "ticket -a") ,
389- get_ticket_cache_key ( "ticket -b")
424+ get_preview_session_cache_key ( "session -a") ,
425+ get_preview_session_cache_key ( "session -b")
390426 ) ;
391427 }
392428}
0 commit comments