diff --git a/backend/crates/gateway-api/src/admin/auth.rs b/backend/crates/gateway-api/src/admin/auth.rs index b0b67a473..acb236a64 100644 --- a/backend/crates/gateway-api/src/admin/auth.rs +++ b/backend/crates/gateway-api/src/admin/auth.rs @@ -5,7 +5,11 @@ use std::fmt; use axum::{ Router, extract::{FromRequestParts, State}, - http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts}, + http::{ + HeaderMap, HeaderValue, StatusCode, + header::{ORIGIN, SET_COOKIE}, + request::Parts, + }, response::{IntoResponse, Response}, routing::{get, post}, }; @@ -15,18 +19,37 @@ use gateway_admin::{ }; use serde::{Deserialize, Serialize}; use tower_http::request_id::RequestId; +use url::Url; use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse}; const REQUEST_ID_HEADER: &str = "x-request-id"; const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session"; -const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax"; /// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。 pub trait AdminSessionState { fn admin_services(&self) -> &AdminServices; } +fn admin_session_cookie_attrs(headers: &HeaderMap) -> &'static str { + // 同源管理端的 POST 由浏览器携带 Origin,HTTPS 反代回源 HTTP 不改变它。 + // 只对明确的 HTTP 来源放宽;缺失、opaque 或非法来源继续使用 Secure。 + let http_origin = headers.get_all(ORIGIN).iter().count() == 1 + && headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_some_and(|origin| { + Url::parse(origin).is_ok_and(|url| { + url.scheme() == "http" && url.origin().ascii_serialization() == origin + }) + }); + if http_origin { + "Path=/; HttpOnly; SameSite=Lax" + } else { + "Path=/; Secure; HttpOnly; SameSite=Lax" + } +} + /// 已通过管理员会话或部署级管理 API Key 鉴权的请求。 pub struct AdminAuth { context: AdminRequestContext, @@ -203,6 +226,7 @@ where async fn login( State(state): State, + headers: HeaderMap, AdminJson(payload): AdminJson, ) -> Result where @@ -221,8 +245,9 @@ where ) .into_response(); let cookie = format!( - "{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}", - session.session_id + "{ADMIN_SESSION_COOKIE}={}; {}", + session.session_id, + admin_session_cookie_attrs(&headers) ); response.headers_mut().insert( SET_COOKIE, @@ -260,7 +285,10 @@ where let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new())) .into_response(); - let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0"); + let cookie = format!( + "{ADMIN_SESSION_COOKIE}=; {}; Max-Age=0", + admin_session_cookie_attrs(&headers) + ); response.headers_mut().insert( SET_COOKIE, HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, diff --git a/backend/crates/gateway-api/tests/openai/router.rs b/backend/crates/gateway-api/tests/openai/router.rs index bbfb81374..a4d54d336 100644 --- a/backend/crates/gateway-api/tests/openai/router.rs +++ b/backend/crates/gateway-api/tests/openai/router.rs @@ -9,6 +9,106 @@ use super::models::ModelsExecution; const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024; +#[tokio::test] +async fn browser_origin_controls_http_admin_sessions_without_configuration() { + use axum::body::to_bytes; + use serde_json::{Value, json}; + for (origins, secure) in [ + (vec!["http://admin.example.test"], false), + (vec!["http://192.0.2.1:8080"], false), + (vec!["http://[::1]:8080"], false), + (vec!["https://admin.example.test"], true), + (vec![], true), + (vec!["null"], true), + (vec!["invalid-origin"], true), + (vec!["http://admin.example.test/path"], true), + (vec!["http://admin.example.test?https"], true), + (vec!["http://user@admin.example.test"], true), + ( + vec!["http://admin.example.test", "https://admin.example.test"], + true, + ), + ] { + let app = api_router_with_origins(ModelsExecution::new(), Vec::new()).await; + let request = |path: &str| { + let mut builder = Request::post(path) + .header("content-type", "application/json") + .header("x-forwarded-proto", "http") + .header("forwarded", "proto=http"); + for origin in &origins { + builder = builder.header("origin", *origin); + } + builder + }; + let response = app + .clone() + .oneshot( + request("/api/admin/auth/login") + .body(Body::from( + json!({"username": "admin_1", "password": "strong-admin-password"}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let cookie = response.headers()["set-cookie"].to_str().unwrap(); + let session = cookie.split(';').next().unwrap().to_owned(); + let attrs: Vec<_> = cookie.split(';').map(str::trim).collect(); + assert_eq!(attrs.contains(&"Secure"), secure); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax"] { + assert!(attrs.contains(&attribute)); + } + + let response = app + .clone() + .oneshot( + Request::get("/api/admin/auth/status") + .header("cookie", &session) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap(); + assert_eq!(body["data"]["authenticated"], true); + + let response = app + .clone() + .oneshot( + request("/api/admin/auth/logout") + .header("cookie", &session) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let cookie = response.headers()["set-cookie"].to_str().unwrap(); + assert!(cookie.starts_with("cpr_admin_session=;")); + let attrs: Vec<_> = cookie.split(';').map(str::trim).collect(); + assert_eq!(attrs.contains(&"Secure"), secure); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"] { + assert!(attrs.contains(&attribute)); + } + + let response = app + .oneshot( + Request::get("/api/admin/auth/status") + .header("cookie", &session) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap(); + assert_eq!(body["data"]["authenticated"], false); + } +} + #[tokio::test] async fn removed_responses_review_route_should_not_reach_the_responses_handler() { let response = api_router_with_origins(ModelsExecution::new(), Vec::new()) diff --git a/deploy/README.md b/deploy/README.md index 7f1c8781b..1e94435fe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -78,12 +78,22 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`。 ## 公网访问 -Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置 HTTPS 反向代理, +Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置反向代理, 不要把 PostgreSQL 或 Redis 暴露到公网。 +同源管理端可直接通过 HTTP 或 HTTPS 登录,无需增加应用配置项。 +登录和退出根据浏览器自动携带的 `Origin` 设置会话 Cookie:HTTP 来源省略 `Secure`, +HTTPS 来源保留 `Secure`,两者都保留 `HttpOnly`、`SameSite=Lax` 和退出时的过期设置。 +因此 HTTPS 反向代理使用 HTTP 回源时,浏览器会话仍使用 `Secure` Cookie。 +缺失、`null` 或非法 `Origin` 时保留 `Secure`,不根据 `X-Forwarded-Proto` 等转发头降级。 +反向代理应原样保留 `Origin`,不要清除它或改写 Cookie 的 `Secure` 属性。 +HTTP 传输不加密,公网部署仍建议使用 HTTPS。 + +HTTP 页面上的 Key 和配置文件复制使用 VueUse 内置兼容路径;支持 Clipboard API 时使用现代 API。 + 反向代理需要保留 `Authorization`,支持 `/v1/responses` 的 WebSocket Upgrade, 并关闭 SSE 响应缓冲。读取超时应覆盖长时间生成任务。 -客户端使用 `https://你的域名/v1`,不要使用前端开发服务的 `5173/dev/v1`。 +客户端使用部署地址下的 `/v1`,协议与部署一致,不要使用前端开发服务的 `5173/dev/v1`。 当前应用只支持单副本,不能通过复制容器扩容。 流式响应在首个上游事件提交后,每 15 秒无输出会发送一次 SSE 注释保活, diff --git a/docs/api.md b/docs/api.md index e28688c1b..cef701a6f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,6 +51,11 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 - 浏览器登录后得到的 `cpr_admin_session` Cookie; - `x-api-key: `。 +同源管理端的登录和退出根据浏览器 `Origin` 自动设置会话 Cookie:HTTP 来源省略 `Secure`, +HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、`SameSite=Lax` 始终保留。 +无需新增配置,不根据 `X-Forwarded-Proto` 等转发头降级;HTTPS 反代使用 HTTP 回源不影响 Cookie。 +部署要求见 [公网访问](../deploy/README.md#公网访问)。 + 请求无需自带 `x-request-id`;缺失时服务端自动生成 UUID 并在响应头回传同一 request ID。 `api.request_id_header` 可改变注入与回传的 header 名,管理端鉴权不依赖该名字。 管理端响应统一带 `Cache-Control: no-store`。 diff --git a/frontend/src/composables/useCopyText.ts b/frontend/src/composables/useCopyText.ts index 53b65e6a5..1e1938861 100644 --- a/frontend/src/composables/useCopyText.ts +++ b/frontend/src/composables/useCopyText.ts @@ -13,7 +13,8 @@ interface CopyTextOptions { // 剪贴板复制 + toast 反馈;空值与错误文案语义由调用方按站点配置。 export function useCopyText() { - const { copy } = useClipboard() + // 普通 HTTP 页面没有 Clipboard API,启用 VueUse 内置的兼容复制路径。 + const { copy } = useClipboard({ legacy: true }) return async function copyText(value: string, options: CopyTextOptions) { if (!value) {