diff --git a/Cargo.lock b/Cargo.lock index 84715fb2f..6f2e7407c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,7 @@ dependencies = [ "serde_yml", "sha2 0.11.0", "sqlx", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 169ae0004..f7895cbc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite", "chrono", "uuid"] } +subtle = "2.6.1" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] } tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] } diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 34a0201b4..6ffa4f4e8 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - admin::auth::session::require_admin_session, + admin::auth::session::require_admin_auth, admin::response::{ AdminEnvelope, AdminError, AdminResponse, CursorPageMeta, NumberedPageMeta, PageMeta, }, @@ -419,7 +419,7 @@ pub(crate) async fn accounts( headers: HeaderMap, Query(params): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let limit = clamp_limit(params.page_size.or(params.limit).unwrap_or(50)); let use_numbered_page = params.page.is_some() || params.page_size.is_some(); let stats = account_list_stats(&state).await; @@ -482,7 +482,7 @@ pub(crate) async fn create_account( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -503,7 +503,7 @@ pub(crate) async fn export_accounts( headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -525,7 +525,7 @@ pub(crate) async fn refresh_account( Json(payload): Json, ) -> Result { let account_id = payload.id; - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -547,7 +547,7 @@ pub(crate) async fn account_quota( Query(query): Query, ) -> Result { let account_id = query.id; - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -579,7 +579,7 @@ pub(crate) async fn import_accounts( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state.services.admin_accounts.import(payload).await { Ok(result) => Ok(AdminResponse::new( StatusCode::OK, @@ -598,7 +598,7 @@ pub(crate) async fn oauth_authorize_account( State(state): State, headers: HeaderMap, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state.services.admin_accounts.oauth_authorize().await { Ok(result) => Ok(AdminResponse::new( StatusCode::OK, @@ -619,7 +619,7 @@ pub(crate) async fn oauth_exchange_account( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -652,7 +652,7 @@ pub(crate) async fn test_account_connection( ) -> Result { let account_id = query.id; let payload = parse_account_test_request(&body)?; - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let model = payload .model_id @@ -693,7 +693,7 @@ pub(crate) async fn account_models( ) -> Result { let request_id = request_id.as_str().to_string(); let account_id = query.id; - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -722,7 +722,7 @@ pub(crate) async fn batch_delete_accounts( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_accounts @@ -746,7 +746,7 @@ pub(crate) async fn update_account( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let ParsedAccountUpdate { id, update } = parse_account_update(&payload)?; diff --git a/src/admin/auth/session.rs b/src/admin/auth/session.rs index c897e0de8..d92422836 100644 --- a/src/admin/auth/session.rs +++ b/src/admin/auth/session.rs @@ -165,6 +165,30 @@ pub(crate) async fn require_admin_session( } } +/// 要求请求携带有效管理员凭证:会话 Cookie 或管理员 API Key。 +pub(crate) async fn require_admin_auth( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), AdminError> { + if let Some(api_key) = admin_api_key_header(headers) { + return match state.services.settings.verify_admin_api_key(&api_key).await { + Ok(true) => Ok(()), + Ok(false) => Err(AdminError::new( + StatusCode::UNAUTHORIZED, + 40103, + "Invalid admin API key", + )), + Err(_) => Err(AdminError::new( + StatusCode::INTERNAL_SERVER_ERROR, + 50001, + "Failed to validate admin API key", + )), + }; + } + + require_admin_session(state, headers).await +} + fn admin_session_cookie(headers: &HeaderMap) -> Option { let cookie = headers.get("cookie")?.to_str().ok()?; cookie.split(';').find_map(|part| { @@ -172,3 +196,8 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option { (name == ADMIN_SESSION_COOKIE).then(|| value.to_string()) }) } + +fn admin_api_key_header(headers: &HeaderMap) -> Option { + let value = headers.get("x-api-key")?.to_str().ok()?.trim(); + (!value.is_empty()).then(|| value.to_string()) +} diff --git a/src/admin/keys/routes.rs b/src/admin/keys/routes.rs index a7dc4a092..5e7242c81 100644 --- a/src/admin/keys/routes.rs +++ b/src/admin/keys/routes.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - admin::auth::session::require_admin_session, + admin::auth::session::require_admin_auth, admin::keys::service::{ AdminClientKeyError, AdminCreatedClientApiKey, AdminStoredClientApiKey, }, @@ -213,7 +213,7 @@ pub(crate) async fn api_keys( headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let limit = clamp_limit(query.limit.unwrap_or(50)); match state .services @@ -241,7 +241,7 @@ pub(crate) async fn create_api_key( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state.services.admin_client_keys.create(&payload.name).await { Ok(created) => Ok(AdminResponse::new( StatusCode::OK, @@ -257,7 +257,7 @@ pub(crate) async fn update_api_key( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let update = parse_client_api_key_update(&payload)?; if let Some(label) = update.label { @@ -301,7 +301,7 @@ pub(crate) async fn batch_delete_api_keys( headers: HeaderMap, Json(payload): Json, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state .services .admin_client_keys diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index 04fa13cd6..2d7891759 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -13,7 +13,7 @@ use serde_json::Value; use crate::{ admin::{ - auth::session::require_admin_session, + auth::session::require_admin_auth, monitoring::{ billing, diagnostics::{ @@ -236,7 +236,7 @@ pub(crate) async fn dashboard_summary( State(state): State, headers: HeaderMap, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let accounts = state .services @@ -296,7 +296,7 @@ pub(crate) async fn dashboard_trend( headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let time_buckets = dashboard_time_buckets(&state, Utc::now()).await; Ok(AdminResponse::new( diff --git a/src/admin/monitoring/logs.rs b/src/admin/monitoring/logs.rs index 4ddef9a16..b1f4886df 100644 --- a/src/admin/monitoring/logs.rs +++ b/src/admin/monitoring/logs.rs @@ -13,7 +13,7 @@ use crate::{ events::{EventLevel, EventLog}, }, admin::{ - auth::session::require_admin_session, + auth::session::require_admin_auth, response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, }, infra::{ @@ -72,7 +72,7 @@ pub(crate) async fn logs( headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let limit = clamp_limit(query.page_size.or(query.limit).unwrap_or(50)); let page = query.page; let use_numbered_page = page.is_some() || query.page_size.is_some(); @@ -123,7 +123,7 @@ pub(crate) async fn log_detail( headers: HeaderMap, Query(query): Query, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state.services.logs.get(&query.id).await { Ok(Some(log)) => Ok(AdminResponse::new( StatusCode::OK, @@ -143,7 +143,7 @@ pub(crate) async fn clear_logs( State(state): State, headers: HeaderMap, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; match state.services.logs.clear().await { Ok(cleared) => Ok(AdminResponse::new( StatusCode::OK, diff --git a/src/admin/router.rs b/src/admin/router.rs index d69516a4c..1456fe8b3 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -19,7 +19,10 @@ use super::{ dashboard::{dashboard_summary, dashboard_trend}, logs::{clear_logs, log_detail, logs}, }, - settings::routes::{settings, update_settings}, + settings::routes::{ + admin_api_key_status, delete_admin_api_key, regenerate_admin_api_key, settings, + update_settings, + }, }; /// 构造管理端路由。 @@ -29,6 +32,14 @@ pub fn router() -> Router { .route("/api/admin/auth/status", get(session_status)) .route("/api/admin/logout", post(logout)) .route("/api/admin/settings", get(settings).post(update_settings)) + .route( + "/api/admin/settings/admin-api-key", + get(admin_api_key_status).delete(delete_admin_api_key), + ) + .route( + "/api/admin/settings/admin-api-key/regenerate", + post(regenerate_admin_api_key), + ) .route("/api/admin/dashboard/summary", get(dashboard_summary)) .route("/api/admin/dashboard/trend", get(dashboard_trend)) .route("/api/admin/accounts", get(accounts).post(create_account)) diff --git a/src/admin/settings/routes.rs b/src/admin/settings/routes.rs index 9a39e9dde..1ab086f89 100644 --- a/src/admin/settings/routes.rs +++ b/src/admin/settings/routes.rs @@ -11,7 +11,7 @@ use serde_json::Value; use std::collections::BTreeMap; use crate::{ - admin::auth::session::require_admin_session, + admin::auth::session::require_admin_auth, admin::response::{AdminEnvelope, AdminError, AdminResponse}, config::{ settings::{AdminSettingsPatch, RuntimeSettingsError, SettingsServiceError}, @@ -61,7 +61,7 @@ pub(crate) async fn settings( State(state): State, headers: HeaderMap, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let config = state.services.settings.current(); Ok(AdminResponse::new( StatusCode::OK, @@ -75,7 +75,7 @@ pub(crate) async fn update_settings( headers: HeaderMap, body: Bytes, ) -> Result { - require_admin_session(&state, &headers).await?; + require_admin_auth(&state, &headers).await?; let patch = parse_settings_patch(&body)?; let config = state @@ -91,6 +91,63 @@ pub(crate) async fn update_settings( )) } +/// `GET /api/admin/settings/admin-api-key` +pub(crate) async fn admin_api_key_status( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + let status = state + .services + .settings + .admin_api_key_status() + .await + .map_err(settings_service_error)?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(status), + )) +} + +/// `POST /api/admin/settings/admin-api-key/regenerate` +pub(crate) async fn regenerate_admin_api_key( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + let key = state + .services + .settings + .regenerate_admin_api_key() + .await + .map_err(settings_service_error)?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ "key": key })), + )) +} + +/// `DELETE /api/admin/settings/admin-api-key` +pub(crate) async fn delete_admin_api_key( + State(state): State, + headers: HeaderMap, +) -> Result { + require_admin_auth(&state, &headers).await?; + state + .services + .settings + .delete_admin_api_key() + .await + .map_err(settings_service_error)?; + + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({ "message": "Admin API key deleted" })), + )) +} + fn parse_settings_patch(body: &[u8]) -> Result { let value = serde_json::from_slice::(body) .map_err(|_| AdminError::new(StatusCode::BAD_REQUEST, 40000, "Malformed JSON body"))?; diff --git a/src/config/settings.rs b/src/config/settings.rs index d860ab24e..3c3cb1306 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -8,9 +8,11 @@ use std::{ use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; +use subtle::ConstantTimeEq; use thiserror::Error; use crate::config::types::AppConfig; +use crate::infra::identity::generate_admin_api_key; use crate::upstream::accounts::pool::{ AccountPoolOptions, RotationStrategy, RuntimeAccountPoolService, }; @@ -228,6 +230,16 @@ fn invalid_field(field: &str, message: impl Into) -> SettingsServiceErro } } +/// 管理员 API Key 状态。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminApiKeyStatus { + /// 是否已经生成。 + pub exists: bool, + /// 脱敏后的密钥。 + pub masked_key: Option, +} + /// 运行时设置服务。 #[derive(Clone)] pub struct RuntimeSettingsService { @@ -295,6 +307,60 @@ impl RuntimeSettingsService { .unwrap_or_else(std::sync::PoisonError::into_inner) = next.clone(); Ok(next) } + + /// 返回管理员 API Key 状态。 + pub async fn admin_api_key_status(&self) -> Result { + admin_api_key_status(&self.pool).await + } + + /// 重新生成管理员 API Key,完整值只在本次返回。 + pub async fn regenerate_admin_api_key(&self) -> Result { + let settings = admin_settings_from_config(&self.current()); + insert_runtime_settings_if_missing(&self.pool, &settings).await?; + + let key = generate_admin_api_key(); + sqlx::query( + r" +update runtime_settings +set admin_api_key = ?, updated_at = ? +where id = ?", + ) + .bind(&key) + .bind(Utc::now().to_rfc3339()) + .bind(RUNTIME_SETTINGS_ID) + .execute(&self.pool) + .await?; + + Ok(key) + } + + /// 删除管理员 API Key。 + pub async fn delete_admin_api_key(&self) -> Result<(), RuntimeSettingsError> { + sqlx::query( + r" +update runtime_settings +set admin_api_key = null, updated_at = ? +where id = ?", + ) + .bind(Utc::now().to_rfc3339()) + .bind(RUNTIME_SETTINGS_ID) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// 校验管理员 API Key。 + pub async fn verify_admin_api_key(&self, key: &str) -> Result { + if key.is_empty() { + return Ok(false); + } + + let stored = load_admin_api_key(&self.pool).await?; + Ok(stored + .as_deref() + .filter(|stored_key| !stored_key.is_empty()) + .is_some_and(|stored_key| key.as_bytes().ct_eq(stored_key.as_bytes()).into())) + } } /// 运行时设置错误。 @@ -458,6 +524,38 @@ where id = ?", Ok(settings) } +async fn admin_api_key_status( + pool: &SqlitePool, +) -> Result { + let key = load_admin_api_key(pool).await?; + let masked_key = key + .as_deref() + .filter(|key| !key.is_empty()) + .map(mask_api_key); + Ok(AdminApiKeyStatus { + exists: masked_key.is_some(), + masked_key, + }) +} + +async fn load_admin_api_key(pool: &SqlitePool) -> Result, RuntimeSettingsError> { + Ok(sqlx::query_scalar::<_, Option>( + "select admin_api_key from runtime_settings where id = ?", + ) + .bind(RUNTIME_SETTINGS_ID) + .fetch_optional(pool) + .await? + .flatten()) +} + +fn mask_api_key(key: &str) -> String { + if key.len() > 14 { + format!("{}...{}", &key[..10], &key[key.len() - 4..]) + } else { + key.to_string() + } +} + fn runtime_settings_from_row(row: &SqliteRow) -> Result { let model_aliases_json: String = row.get("model_aliases_json"); Ok(AdminSettings { diff --git a/src/infra/identity.rs b/src/infra/identity.rs index 66f9f5c07..b20434ba6 100644 --- a/src/infra/identity.rs +++ b/src/infra/identity.rs @@ -1,4 +1,4 @@ -//! 身份认证相关原语:管理员密码哈希与客户端 API Key 生成。 +//! 身份认证相关原语:管理员密码哈希与 API Key 生成。 use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, @@ -66,3 +66,10 @@ pub fn generate_client_api_key() -> GeneratedClientApiKey { let prefix = key.chars().take(12).collect::(); GeneratedClientApiKey { key, prefix } } + +/// 生成新的管理员 API Key。 +pub fn generate_admin_api_key() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + format!("admin-{}", hex::encode(bytes)) +} diff --git a/src/infra/schema.sql b/src/infra/schema.sql index d6deca503..94cd0ff31 100644 --- a/src/infra/schema.sql +++ b/src/infra/schema.sql @@ -50,6 +50,7 @@ create table if not exists runtime_settings ( max_concurrent_per_account integer not null check (max_concurrent_per_account > 0), request_interval_ms integer not null check (request_interval_ms >= 0), rotation_strategy text not null check (rotation_strategy in ('least_used', 'round_robin', 'sticky')), + admin_api_key text, updated_at text not null ); diff --git a/tests/admin/settings/routes.rs b/tests/admin/settings/routes.rs index f3867f9f8..a93976a5c 100644 --- a/tests/admin/settings/routes.rs +++ b/tests/admin/settings/routes.rs @@ -66,6 +66,175 @@ async fn admin_settings_should_return_runtime_fields() { assert_eq!(body["data"]["rotationStrategy"], "least_used"); } +#[tokio::test] +async fn admin_settings_admin_api_key_status_should_be_empty_by_default() { + let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-empty.sqlite").await; + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/settings/admin-api-key") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_empty") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["exists"], false); + assert!(body["data"]["maskedKey"].is_null()); +} + +#[tokio::test] +async fn admin_settings_admin_api_key_should_regenerate_and_return_masked_status() { + let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-regenerate.sqlite").await; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/settings/admin-api-key/regenerate") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_regenerate") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + let key = body["data"]["key"].as_str().unwrap(); + assert!(key.starts_with("admin-")); + assert_eq!(key.len(), 70); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/settings/admin-api-key") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["exists"], true); + assert_eq!( + body["data"]["maskedKey"], + format!("{}...{}", &key[..10], &key[key.len() - 4..]) + ); +} + +#[tokio::test] +async fn admin_settings_should_accept_valid_admin_api_key_header() { + let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-auth.sqlite").await; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/settings/admin-api-key/regenerate") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_auth_create") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let key = body["data"]["key"].as_str().unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/settings") + .header("x-api-key", key) + .header("x-request-id", "req_settings_admin_key_auth") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["refreshMarginSeconds"], 240); +} + +#[tokio::test] +async fn admin_settings_should_reject_invalid_admin_api_key_header() { + let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-invalid.sqlite").await; + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/settings") + .header("x-api-key", "admin-invalid") + .header("x-request-id", "req_settings_admin_key_invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response_json(response).await["code"], 40103); +} + +#[tokio::test] +async fn admin_settings_admin_api_key_should_delete_and_revoke_access() { + let (app, _dir) = admin_settings_test_app("admin-settings-admin-key-delete.sqlite").await; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/settings/admin-api-key/regenerate") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_delete_create") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let key = body["data"]["key"].as_str().unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/admin/settings/admin-api-key") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_settings_admin_key_delete") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/admin/settings") + .header("x-api-key", key) + .header("x-request-id", "req_settings_admin_key_revoked") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response_json(response).await["code"], 40103); +} + #[tokio::test] async fn admin_settings_update_should_require_admin_session_cookie() { let (app, _dir) = admin_settings_test_app("admin-settings-update-auth.sqlite").await; diff --git a/tests/infra/storage_schema/mod.rs b/tests/infra/storage_schema/mod.rs index dfdc40586..f8444e261 100644 --- a/tests/infra/storage_schema/mod.rs +++ b/tests/infra/storage_schema/mod.rs @@ -44,7 +44,7 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() { let pool = connect_sqlite(&url).await.unwrap(); let rows: Vec<(String,)> = sqlx::query_as( - "select name from pragma_table_info('runtime_settings') where name in ('id', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'updated_at') order by name", + "select name from pragma_table_info('runtime_settings') where name in ('id', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'admin_api_key', 'updated_at') order by name", ) .fetch_all(&pool) .await @@ -53,6 +53,7 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() { assert_eq!( rows.into_iter().map(|row| row.0).collect::>(), [ + "admin_api_key", "id", "max_concurrent_per_account", "model_aliases_json", diff --git a/web/src/api/modules/settings.ts b/web/src/api/modules/settings.ts index 8c9d3ace8..5d918de97 100644 --- a/web/src/api/modules/settings.ts +++ b/web/src/api/modules/settings.ts @@ -14,3 +14,24 @@ export function updateSettings(data: any) { data, }) } + +export function getAdminApiKeyStatus() { + return request({ + url: '/api/admin/settings/admin-api-key', + method: 'GET', + }) +} + +export function regenerateAdminApiKey() { + return request({ + url: '/api/admin/settings/admin-api-key/regenerate', + method: 'POST', + }) +} + +export function deleteAdminApiKey() { + return request({ + url: '/api/admin/settings/admin-api-key', + method: 'DELETE', + }) +} diff --git a/web/src/layout/index.vue b/web/src/layout/index.vue index c37f03672..d83bb24d3 100644 --- a/web/src/layout/index.vue +++ b/web/src/layout/index.vue @@ -28,8 +28,10 @@ watch(
- - + + + +
diff --git a/web/src/views/dashboard/components/AccountOverviewCard.vue b/web/src/views/dashboard/components/AccountOverviewCard.vue index 119b7c7ae..40ade9e9d 100644 --- a/web/src/views/dashboard/components/AccountOverviewCard.vue +++ b/web/src/views/dashboard/components/AccountOverviewCard.vue @@ -139,7 +139,7 @@ const quotaToneClasses: Record = { class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0" >

账号调度

diff --git a/web/src/views/dashboard/index.vue b/web/src/views/dashboard/index.vue index c045353db..57c841f09 100644 --- a/web/src/views/dashboard/index.vue +++ b/web/src/views/dashboard/index.vue @@ -31,7 +31,7 @@ const {