mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(admin): support HTTP login and clipboard without configuration
Support same-origin HTTP admin sessions without an additional configuration setting. Derive Cookie security from a validated browser Origin, retain Secure for HTTPS and unknown origins, and enable VueUse's existing clipboard fallback. Validated with 312 API tests, 36 gateway tests, strict workspace Clippy, frontend lint/typecheck/build, and browser login/reload/logout over HTTP and HTTPS-to-HTTP proxying. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminJson(payload): AdminJson<AdminLoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
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())?,
|
||||
|
||||
@@ -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())
|
||||
|
||||
+12
-2
@@ -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 注释保活,
|
||||
|
||||
@@ -51,6 +51,11 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全
|
||||
- 浏览器登录后得到的 `cpr_admin_session` Cookie;
|
||||
- `x-api-key: <admin-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`。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user