diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index bf29a9bdd..5320256c9 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -104,10 +104,16 @@ impl fmt::Debug for InitialAdminPassword { #[derive(Clone, Deserialize, PartialEq, Eq)] pub struct AdminConfig { pub session_ttl_minutes: u64, + #[serde(default = "default_absolute_session_ttl_minutes")] + pub session_absolute_ttl_minutes: u64, pub default_username: String, pub default_password: InitialAdminPassword, } +fn default_absolute_session_ttl_minutes() -> u64 { + 30 * 24 * 60 +} + /// Client 登录域的通用启动配置 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct ClientConfig { @@ -151,6 +157,13 @@ impl AdminConfig { if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() { return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes")); } + if self.session_absolute_ttl_minutes == 0 + || i64::try_from(self.session_absolute_ttl_minutes).is_err() + { + return Err(AdminConfigError::InvalidField( + "admin.session_absolute_ttl_minutes", + )); + } let password = self.default_password.expose().trim(); if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES || password.contains('$') @@ -167,6 +180,10 @@ impl fmt::Debug for AdminConfig { formatter .debug_struct("AdminConfig") .field("session_ttl_minutes", &self.session_ttl_minutes) + .field( + "session_absolute_ttl_minutes", + &self.session_absolute_ttl_minutes, + ) .field("default_username", &self.default_username) .field("default_password", &"[REDACTED]") .finish() @@ -401,6 +418,7 @@ async fn initialize_inner( let auth = Arc::new(DefaultAuthService::new( config.default_username, config.session_ttl_minutes, + config.session_absolute_ttl_minutes, client_config.session_ttl_minutes, store.auth(), client_key_verifier.clone(), diff --git a/backend/crates/gateway-admin/src/model/auth.rs b/backend/crates/gateway-admin/src/model/auth.rs index 280c30ac8..45b51b0dc 100644 --- a/backend/crates/gateway-admin/src/model/auth.rs +++ b/backend/crates/gateway-admin/src/model/auth.rs @@ -138,11 +138,12 @@ impl std::fmt::Debug for ChangePassword { } } -/// 两种登录方式共用的固定有效期会话 +/// 会话有效期与管理员续期上限;没有上限字段的旧会话保持固定有效期 #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthSession { pub subject: SessionSubject, pub expires_at: DateTime, + pub absolute_expires_at: Option>, } /// 安全审计事件类型 diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 522a1ca3f..0c713979b 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -274,6 +274,14 @@ pub trait AuthStore: Send + Sync { async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>; + /// 只延长仍存在且匹配的会话;并发续期返回当前值,已退出或过期时不重建 + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSession, + expires_at: chrono::DateTime, + ) -> AdminStoreResult>; + async fn delete_session(&self, session_id: &str) -> AdminStoreResult>; async fn client_key_enabled( diff --git a/backend/crates/gateway-admin/src/use_case/auth.rs b/backend/crates/gateway-admin/src/use_case/auth.rs index 01285f4e6..334b5763a 100644 --- a/backend/crates/gateway-admin/src/use_case/auth.rs +++ b/backend/crates/gateway-admin/src/use_case/auth.rs @@ -36,6 +36,10 @@ pub trait AuthService: Send + Sync { ) -> Result<(), AdminError>; async fn ensure_default_admin(&self, password: &str) -> Result; async fn session(&self, session_id: Option<&str>) -> Result, AdminError>; + async fn renew_session( + &self, + session_id: Option<&str>, + ) -> Result, AdminError>; async fn resolve_admin_user_id( &self, session_id: Option<&str>, @@ -58,6 +62,7 @@ const LOGIN_ATTEMPTS_GLOBAL: u32 = 200; pub(crate) struct DefaultAuthService { default_admin_user_id: String, admin_session_ttl: Duration, + admin_session_absolute_ttl: Duration, key_session_ttl: Duration, store: Arc, verifier: Arc, @@ -68,6 +73,7 @@ impl DefaultAuthService { pub(crate) fn new( default_admin_user_id: impl Into, admin_session_ttl_minutes: u64, + admin_session_absolute_ttl_minutes: u64, key_session_ttl_minutes: u64, store: Arc, verifier: Arc, @@ -75,6 +81,7 @@ impl DefaultAuthService { Self { default_admin_user_id: default_admin_user_id.into(), admin_session_ttl: session_ttl(admin_session_ttl_minutes), + admin_session_absolute_ttl: session_ttl(admin_session_absolute_ttl_minutes), key_session_ttl: session_ttl(key_session_ttl_minutes), store, verifier, @@ -232,8 +239,12 @@ impl AuthService for DefaultAuthService { else { return Ok(None); }; - if session.expires_at <= Utc::now() { - let _ = self.store.delete_session(session_id).await; + if session.expires_at <= Utc::now() + || session + .absolute_expires_at + .is_some_and(|expiry| expiry <= Utc::now()) + { + // Redis 的 TTL 负责过期清理,避免旧读取删除另一个请求刚续期的会话 return Ok(None); } if let SessionSubject::Admin { @@ -266,6 +277,37 @@ impl AuthService for DefaultAuthService { Ok(Some(session)) } + async fn renew_session( + &self, + session_id: Option<&str>, + ) -> Result, AdminError> { + let Some(session_id) = session_id else { + return Ok(None); + }; + let Some(session) = self.session(Some(session_id)).await? else { + return Ok(None); + }; + // Key 与升级前的会话保留原有期限,不能凭续期获得新的最长登录窗口 + let Some(absolute) = session + .absolute_expires_at + .filter(|_| matches!(session.subject, SessionSubject::Admin { .. })) + else { + return Ok(Some(session)); + }; + let now = Utc::now(); + let expires_at = (now + self.admin_session_ttl).min(absolute); + let interval = (self.admin_session_ttl / 4).min(Duration::minutes(1)); + if expires_at <= session.expires_at + || session.expires_at - now > self.admin_session_ttl - interval + { + return Ok(Some(session)); + } + self.store + .renew_session(session_id, &session, expires_at) + .await + .map_err(|error| map_store_error(error, "authentication session renewal")) + } + async fn resolve_admin_user_id( &self, session_id: Option<&str>, @@ -325,9 +367,13 @@ impl AuthService for DefaultAuthService { SessionSubject::Admin { .. } => self.admin_session_ttl, SessionSubject::Key { .. } => self.key_session_ttl, }; + let now = Utc::now(); + let absolute_expires_at = matches!(subject, SessionSubject::Admin { .. }) + .then_some(now + self.admin_session_absolute_ttl); let session = AuthSession { subject, - expires_at: Utc::now() + ttl, + expires_at: (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl)), + absolute_expires_at, }; let session_id = random_session_token(); self.store diff --git a/backend/crates/gateway-admin/tests/backup/support.rs b/backend/crates/gateway-admin/tests/backup/support.rs index 60b105890..db441c02f 100644 --- a/backend/crates/gateway-admin/tests/backup/support.rs +++ b/backend/crates/gateway-admin/tests/backup/support.rs @@ -683,6 +683,15 @@ impl AuthStore for FakeAuthStore { ) -> AdminStoreResult<()> { Ok(()) } + async fn renew_session( + &self, + _: &str, + _: &gateway_admin::model::auth::AuthSession, + _: chrono::DateTime, + ) -> AdminStoreResult> { + Ok(None) + } + async fn delete_session( &self, _session_id: &str, diff --git a/backend/crates/gateway-admin/tests/use_case/auth.rs b/backend/crates/gateway-admin/tests/use_case/auth.rs index bbf7da7ff..7921dfc84 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth.rs @@ -69,6 +69,7 @@ async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() admin_user_id: "admin".into(), credential_fingerprint: String::new(), }, + absolute_expires_at: None, expires_at: Utc::now() + TimeDelta::hours(1), }, ); @@ -137,6 +138,25 @@ impl AuthStore for MemoryAuthStore { Ok(()) } + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSession, + expires_at: chrono::DateTime, + ) -> AdminStoreResult> { + let mut sessions = self.sessions.lock().unwrap(); + if let Some(session) = sessions.get_mut(session_id) { + if session.expires_at <= chrono::Utc::now() { + return Ok(None); + } + if session == expected { + session.expires_at = expires_at; + } + return Ok(Some(session.clone())); + } + Ok(None) + } + async fn delete_session(&self, session_id: &str) -> AdminStoreResult> { if self.reject_delete.lock().unwrap().as_deref() == Some(session_id) { return Err(super::unavailable("delete session")); @@ -262,7 +282,7 @@ async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking() } #[tokio::test] -async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() { +async fn expired_sessions_are_rejected_and_store_outages_are_not_treated_as_logout() { use gateway_admin::model::{AdminErrorKind, auth::SessionSubject}; let store = std::sync::Arc::new(MemoryAuthStore::default()); let services = super::AdminHarness::new().auth(store.clone()).build().await; @@ -273,6 +293,7 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() - TimeDelta::seconds(1), }, ); @@ -284,7 +305,6 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou .unwrap() .is_none() ); - assert!(store.sessions.lock().unwrap().is_empty()); store .unavailable .store(true, std::sync::atomic::Ordering::SeqCst); @@ -370,3 +390,110 @@ async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_ret services.auth().logout(&original.session_id).await.unwrap(); assert!(store.sessions.lock().unwrap().is_empty()); } + +#[tokio::test] +async fn renewal_is_throttled_and_capped_by_the_original_absolute_expiry() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = super::AdminHarness::new() + .auth(store.clone()) + .session_ttl_minutes(60) + .build() + .await; + let auth = services.auth(); + let login = auth + .login( + LoginCommand::Admin { + username: None, + password: "strong-test-password".into(), + }, + std::net::Ipv4Addr::LOCALHOST.into(), + None, + ) + .await + .unwrap(); + assert_eq!( + auth.renew_session(Some(&login.session_id)).await.unwrap(), + Some(login.session.clone()) + ); + let limit = Utc::now() + TimeDelta::minutes(30); + { + let mut sessions = store.sessions.lock().unwrap(); + let session = sessions.get_mut(&login.session_id).unwrap(); + session.expires_at = Utc::now() + TimeDelta::minutes(1); + session.absolute_expires_at = Some(limit); + } + let renewed = auth + .renew_session(Some(&login.session_id)) + .await + .unwrap() + .unwrap(); + assert_eq!(renewed.expires_at, limit); + assert_eq!(renewed.absolute_expires_at, Some(limit)); + assert_eq!( + auth.renew_session(Some(&login.session_id)).await.unwrap(), + Some(renewed) + ); +} + +#[tokio::test] +async fn renewal_never_revives_expired_revoked_or_legacy_sessions() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = super::AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + let login = auth + .login( + LoginCommand::Admin { + username: None, + password: "strong-test-password".into(), + }, + std::net::Ipv4Addr::LOCALHOST.into(), + None, + ) + .await + .unwrap(); + let mut session = login.session.clone(); + session.absolute_expires_at = None; + session.expires_at = Utc::now() + TimeDelta::seconds(30); + store + .sessions + .lock() + .unwrap() + .insert(login.session_id.clone(), session.clone()); + assert_eq!( + auth.renew_session(Some(&login.session_id)).await.unwrap(), + Some(session.clone()) + ); + session.expires_at = Utc::now() - TimeDelta::seconds(1); + store + .sessions + .lock() + .unwrap() + .insert(login.session_id.clone(), session); + assert!( + auth.renew_session(Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + let mut session = login.session; + session.absolute_expires_at = Some(Utc::now() - TimeDelta::seconds(1)); + store + .sessions + .lock() + .unwrap() + .insert(login.session_id.clone(), session); + assert!( + auth.renew_session(Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + auth.logout(&login.session_id).await.unwrap(); + assert!( + auth.renew_session(Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + assert!(store.sessions.lock().unwrap().is_empty()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/auth_key.rs b/backend/crates/gateway-admin/tests/use_case/auth_key.rs index 5d78319fc..15f615c35 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth_key.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth_key.rs @@ -285,6 +285,25 @@ impl AuthStore for MemoryKeyAuthStore { Ok(()) } + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSession, + expires_at: chrono::DateTime, + ) -> AdminStoreResult> { + let mut sessions = self.sessions.lock().unwrap(); + if let Some(session) = sessions.get_mut(session_id) { + if session.expires_at <= chrono::Utc::now() { + return Ok(None); + } + if session == expected { + session.expires_at = expires_at; + } + return Ok(Some(session.clone())); + } + Ok(None) + } + async fn delete_session(&self, session_id: &str) -> AdminStoreResult> { Ok(self .sessions diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index b7179a6f6..b8f0aa61e 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -246,6 +246,7 @@ impl AdminHarness { pub(super) async fn build_bundle(self) -> gateway_admin::AdminBundle { gateway_admin::initialize( AdminConfig { + session_absolute_ttl_minutes: 30 * 24 * 60, session_ttl_minutes: self.session_ttl_minutes, default_username: "admin".to_owned(), default_password: InitialAdminPassword::new(self.default_password), @@ -364,6 +365,15 @@ impl AuthStore for BootstrapAuthStore { Err(unavailable("admin session")) } + async fn renew_session( + &self, + _: &str, + _: &gateway_admin::model::auth::AuthSession, + _: chrono::DateTime, + ) -> AdminStoreResult> { + Ok(None) + } + async fn delete_session(&self, _: &str) -> AdminStoreResult> { Err(unavailable("admin session")) } diff --git a/backend/crates/gateway-api/src/auth.rs b/backend/crates/gateway-api/src/auth.rs index 094ce2db8..098d7752e 100644 --- a/backend/crates/gateway-api/src/auth.rs +++ b/backend/crates/gateway-api/src/auth.rs @@ -114,6 +114,7 @@ where Router::new() .route("/api/auth/login", post(login::)) .route("/api/auth/status", get(session_status::)) + .route("/api/auth/refresh", post(refresh_session::)) .route("/api/auth/logout", post(logout::)) .route("/api/auth/password", post(change_password::)) .route("/api/auth", any(not_found)) @@ -141,32 +142,16 @@ where ) .await .map_err(map_login_error)?; - let max_age = result - .session - .expires_at - .signed_duration_since(chrono::Utc::now()) - .num_seconds() - .max(1); - let expires = result - .session - .expires_at - .format("%a, %d %b %Y %H:%M:%S GMT"); - let cookie = format!( - "{}={}; {}; Max-Age={max_age}; Expires={expires}", - session_cookie::NAME, - result.session_id, - session_cookie::attributes(&headers) - ); - let mut response = AdminResponse::new( - StatusCode::OK, - AdminEnvelope::ok(SessionData::from(&result.session)), + set_session_cookie( + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(SessionData::from(&result.session)), + ) + .into_response(), + &headers, + &result.session_id, + &result.session, ) - .into_response(); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, - ); - Ok(response) } async fn session_status( @@ -182,13 +167,64 @@ where .session(session_cookie::value(&headers).as_deref()) .await .map_err(map_admin_service_error)?; - Ok(AdminResponse::new( + Ok(session_response(session.as_ref())) +} + +async fn refresh_session( + State(state): State, + headers: HeaderMap, +) -> Result +where + S: SessionState + Send + Sync, +{ + let session_id = session_cookie::value(&headers); + let session = state + .admin_services() + .auth() + .renew_session(session_id.as_deref()) + .await + .map_err(map_admin_service_error)?; + let response = session_response(session.as_ref()); + if let (Some(session_id), Some(session)) = (session_id, session) { + return set_session_cookie(response, &headers, &session_id, &session); + } + // 失效响应不清 Cookie,避免晚到的请求覆盖另一个标签页刚建立的会话 + Ok(response) +} + +fn session_response(session: Option<&AuthSession>) -> Response { + AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(SessionStatusData { authenticated: session.is_some(), - session: session.as_ref().map(SessionData::from), + session: session.map(SessionData::from), }), - )) + ) + .into_response() +} + +fn set_session_cookie( + mut response: Response, + headers: &HeaderMap, + session_id: &str, + session: &AuthSession, +) -> Result { + let max_age = session + .expires_at + .signed_duration_since(chrono::Utc::now()) + .num_seconds() + .max(1); + let expires = session.expires_at.format("%a, %d %b %Y %H:%M:%S GMT"); + let cookie = format!( + "{}={session_id}; {}; Max-Age={max_age}; Expires={expires}", + session_cookie::NAME, + session_cookie::attributes(headers) + ); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, + ); + Ok(response) } async fn logout(State(state): State, headers: HeaderMap) -> Result diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index ea41ced69..823d89f12 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -185,6 +185,7 @@ impl AdminTestFixture { ]; let bundle = gateway_admin::initialize( AdminConfig { + session_absolute_ttl_minutes: 30 * 24 * 60, session_ttl_minutes: 60, default_username: "admin_1".to_owned(), default_password: InitialAdminPassword::new("strong-admin-password"), @@ -313,6 +314,7 @@ impl MemoryAuthStore { }, admin_user_id: "admin_1".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() + Duration::hours(1), }, ); @@ -326,6 +328,15 @@ impl MemoryAuthStore { self.fail_audit.store(fail, Ordering::SeqCst); } + pub fn set_session_expiry(&self, session_id: &str, expires_at: chrono::DateTime) { + self.sessions + .lock() + .unwrap() + .get_mut(session_id) + .unwrap() + .expires_at = expires_at; + } + pub fn session_count(&self) -> usize { self.sessions.lock().expect("sessions").len() } @@ -397,6 +408,25 @@ impl AuthStore for MemoryAuthStore { Ok(()) } + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSession, + expires_at: chrono::DateTime, + ) -> AdminStoreResult> { + let mut sessions = self.sessions.lock().unwrap(); + if let Some(session) = sessions.get_mut(session_id) { + if session.expires_at <= chrono::Utc::now() { + return Ok(None); + } + if session == expected { + session.expires_at = expires_at; + } + return Ok(Some(session.clone())); + } + Ok(None) + } + async fn delete_session(&self, session_id: &str) -> AdminStoreResult> { Ok(self.sessions.lock().expect("sessions").remove(session_id)) } diff --git a/backend/crates/gateway-api/tests/auth.rs b/backend/crates/gateway-api/tests/auth.rs index 2bc4f3323..90e015aef 100644 --- a/backend/crates/gateway-api/tests/auth.rs +++ b/backend/crates/gateway-api/tests/auth.rs @@ -535,3 +535,97 @@ async fn key_auth_should_issue_restore_and_clear_a_unified_cookie() { .expect("logged out"); assert_eq!(response_json(status).await["data"]["authenticated"], false); } + +#[tokio::test] +async fn refresh_renews_admin_cookie_and_preserves_fixed_key_expiry() { + let (app, fixture) = auth_app().await; + for body in [ + json!({"mode": "admin", "password": "strong-admin-password"}), + json!({"mode": "key", "apiKey": RAW_KEY}), + ] { + let response = login(&app, body.clone(), None).await; + let cookie = session_cookie(&response); + let original = response_json(response).await["data"]["expiresAt"] + .as_str() + .unwrap() + .to_owned(); + if body["mode"] == "admin" { + fixture.set_session_expiry( + cookie.split_once('=').unwrap().1, + chrono::Utc::now() + chrono::Duration::minutes(1), + ); + } + let response = app + .clone() + .oneshot(cookie_request(Method::POST, "/api/auth/refresh", &cookie)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(session_cookie(&response), cookie); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .contains("HttpOnly") + ); + let data = response_json(response).await; + assert_eq!(data["data"]["authenticated"], true); + let expiry = data["data"]["session"]["expiresAt"].as_str().unwrap(); + if body["mode"] == "key" { + assert_eq!(expiry, original); + } else { + assert!( + chrono::DateTime::parse_from_rfc3339(expiry).unwrap() + > chrono::Utc::now() + chrono::Duration::minutes(1) + ); + } + } +} + +#[tokio::test] +async fn refresh_after_logout_or_password_change_cannot_recreate_a_session() { + let (app, _) = auth_app().await; + for password_change in [false, true] { + let cookie = session_cookie( + &login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None, + ) + .await, + ); + let response = if password_change { + change_password( + &app, + Some(&cookie), + "strong-admin-password", + "new-strong-admin-password", + ) + .await + } else { + app.clone() + .oneshot(cookie_request(Method::POST, "/api/auth/logout", &cookie)) + .await + .unwrap() + }; + assert_eq!(response.status(), StatusCode::OK); + let response = app + .clone() + .oneshot(cookie_request(Method::POST, "/api/auth/refresh", &cookie)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert_eq!( + response_json(response).await["data"]["authenticated"], + false + ); + assert_eq!( + get(&app, "/api/admin/system/version", &cookie) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index a2e2b5e13..5b6ce314b 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -525,6 +525,7 @@ impl Environment { .expect("runtime account access"); gateway_admin::initialize_with_plugin_accounts( AdminConfig { + session_absolute_ttl_minutes: 30 * 24 * 60, session_ttl_minutes: 60, default_username: "plugin-test-admin".to_owned(), default_password: InitialAdminPassword::new("plugin-test-password"), diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 8c9ff2106..1d2582d15 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -328,30 +328,33 @@ impl AuthStore for AuthStoreAdapter { } async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> { - let subject = match &session.subject { - SessionSubject::Admin { - admin_user_id, - credential_fingerprint, - } => redis::SessionSubjectRecord::Admin { - admin_user_id: admin_user_id.clone(), - credential_fingerprint: credential_fingerprint.clone(), - }, - SessionSubject::Key { client_key_id } => redis::SessionSubjectRecord::Key { - client_key_id: client_key_id.as_str().to_owned(), - }, - }; redis::AuthStateRepository::store_session( &self.state, session_id, - &redis::AuthSessionRecord { - subject, - expires_at: session.expires_at, - }, + &auth_session_record(session), ) .await .map_err(|error| admin_store_error("authentication session", error)) } + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSession, + expires_at: chrono::DateTime, + ) -> AdminStoreResult> { + redis::AuthStateRepository::renew_session( + &self.state, + session_id, + &auth_session_record(expected), + expires_at, + ) + .await + .map_err(|error| admin_store_error("authentication session renewal", error))? + .map(auth_session) + .transpose() + } + async fn delete_session(&self, session_id: &str) -> AdminStoreResult> { redis::AuthStateRepository::delete_session(&self.state, session_id) .await @@ -446,5 +449,26 @@ fn auth_session(record: redis::AuthSessionRecord) -> AdminStoreResult redis::AuthSessionRecord { + let subject = match &session.subject { + SessionSubject::Admin { + admin_user_id, + credential_fingerprint, + } => redis::SessionSubjectRecord::Admin { + admin_user_id: admin_user_id.clone(), + credential_fingerprint: credential_fingerprint.clone(), + }, + SessionSubject::Key { client_key_id } => redis::SessionSubjectRecord::Key { + client_key_id: client_key_id.as_str().to_owned(), + }, + }; + redis::AuthSessionRecord { + subject, + expires_at: session.expires_at, + absolute_expires_at: session.absolute_expires_at, + } +} diff --git a/backend/crates/gateway-store/src/redis/auth.rs b/backend/crates/gateway-store/src/redis/auth.rs index 33511395b..a1c53d7eb 100644 --- a/backend/crates/gateway-store/src/redis/auth.rs +++ b/backend/crates/gateway-store/src/redis/auth.rs @@ -54,6 +54,7 @@ impl SessionSubjectRecord { pub struct AuthSessionRecord { pub subject: SessionSubjectRecord, pub expires_at: DateTime, + pub absolute_expires_at: Option>, } impl AuthSessionRecord { @@ -81,6 +82,12 @@ pub trait AuthStateRepository: Send + Sync { async fn store_session(&self, session_id: &str, session: &AuthSessionRecord) -> StoreResult<()>; async fn delete_session(&self, session_id: &str) -> StoreResult>; + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSessionRecord, + expires_at: DateTime, + ) -> StoreResult>; async fn consume_login_attempt( &self, source: &str, @@ -120,6 +127,45 @@ impl RedisAuthStateRepository { #[async_trait] impl AuthStateRepository for RedisAuthStateRepository { + async fn renew_session( + &self, + session_id: &str, + expected: &AuthSessionRecord, + expires_at: DateTime, + ) -> StoreResult> { + if expires_at < expected.expires_at + || expected + .absolute_expires_at + .is_none_or(|limit| expires_at > limit) + { + return Err(auth_invalid("session renewal is outside its lifetime")); + } + let renewed = AuthSessionRecord { + expires_at, + ..expected.clone() + }; + let expiry = renewed.validate()?; + // 比较和写入在同一脚本完成;GET 会检查 Redis 到期,退出后的键不能复活 + let payload: Option = Script::new( + r#" +local current = redis.call('GET', KEYS[1]) +if current == ARGV[1] then + redis.call('SET', KEYS[1], ARGV[2], 'PXAT', ARGV[3], 'XX') + return ARGV[2] +end +return current +"#, + ) + .key(self.session_key(session_id)?) + .arg(encode_session(expected)?) + .arg(encode_session(&renewed)?) + .arg(expiry) + .invoke_async(&mut self.connection.clone()) + .await + .map_err(|_| redis_unavailable("renew authentication session"))?; + payload.map(|value| decode_session(&value)).transpose() + } + async fn load_session(&self, session_id: &str) -> StoreResult> { let key = self.session_key(session_id)?; let mut connection = self.connection.clone(); @@ -202,6 +248,8 @@ impl AuthStateRepository for RedisAuthStateRepository { struct AuthSessionWire { subject: SessionSubjectRecord, expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + absolute_expires_at: Option, } fn encode_session(session: &AuthSessionRecord) -> StoreResult { @@ -210,6 +258,9 @@ fn encode_session(session: &AuthSessionRecord) -> StoreResult { expires_at: session .expires_at .to_rfc3339_opts(SecondsFormat::Nanos, true), + absolute_expires_at: session + .absolute_expires_at + .map(|value| value.to_rfc3339_opts(SecondsFormat::Nanos, true)), }) .map_err(|_| auth_invalid("session value cannot be encoded")) } @@ -227,6 +278,7 @@ fn decode_session(value: &str) -> StoreResult { Ok(AuthSessionRecord { subject: wire.subject, expires_at, + absolute_expires_at: wire.absolute_expires_at.as_deref().map(parse).transpose()?, }) } diff --git a/backend/crates/gateway-store/tests/redis/auth.rs b/backend/crates/gateway-store/tests/redis/auth.rs index b1f2b8f66..03e07bd29 100644 --- a/backend/crates/gateway-store/tests/redis/auth.rs +++ b/backend/crates/gateway-store/tests/redis/auth.rs @@ -1,4 +1,4 @@ -//! 验证 Redis 认证会话的固定有效期、敏感键保护与原子限流 +//! 验证 Redis 认证会话的续期、撤销、固定有效期与原子限流 use std::time::Duration; @@ -20,6 +20,7 @@ async fn client_session_should_use_fixed_ttl_without_storing_session_or_api_key_ subject: SessionSubjectRecord::Key { client_key_id: "key-42".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }; @@ -115,6 +116,7 @@ async fn client_session_should_reject_invalid_intervals_and_rate_limit_policies( subject: SessionSubjectRecord::Key { client_key_id: "key-42".to_owned(), }, + absolute_expires_at: None, expires_at: now, }; @@ -154,6 +156,7 @@ fn admin_auth_state_rejects_invalid_ttl_boundaries() { credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() - chrono::Duration::seconds(1), }; let runtime = tokio::runtime::Runtime::new().expect("test runtime"); @@ -180,6 +183,7 @@ async fn admin_auth_state_keeps_fixed_ttl_and_opaque_keys() { credential_fingerprint: "test-password-fingerprint".to_owned(), admin_user_id: "default-admin".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }; @@ -248,6 +252,7 @@ async fn unified_session_rejects_invalid_identities_and_loads_legacy_admin_paylo subject: SessionSubjectRecord::Key { client_key_id: "key-42".to_owned(), }, + absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }, ) @@ -303,3 +308,118 @@ async fn unified_session_rejects_invalid_identities_and_loads_legacy_admin_paylo } ); } + +#[tokio::test] +async fn renewal_is_atomic_monotonic_and_cannot_resurrect_a_deleted_session() { + let Some((repository, _, _)) = auth_repository().await else { + return; + }; + let original = AuthSessionRecord { + subject: SessionSubjectRecord::Admin { + admin_user_id: "admin".into(), + credential_fingerprint: "fingerprint".into(), + }, + expires_at: Utc::now() + chrono::Duration::minutes(1), + absolute_expires_at: Some(Utc::now() + chrono::Duration::minutes(10)), + }; + repository + .store_session("renewal", &original) + .await + .unwrap(); + let expiry = Utc::now() + chrono::Duration::minutes(5); + let (first, second) = tokio::join!( + repository.renew_session("renewal", &original, expiry), + repository.renew_session("renewal", &original, expiry + chrono::Duration::minutes(1)), + ); + let first = first.unwrap().unwrap(); + assert_eq!(Some(first.clone()), second.unwrap()); + assert_eq!(first.absolute_expires_at, original.absolute_expires_at); + assert!(first.expires_at >= expiry); + assert_eq!( + repository + .renew_session("renewal", &original, original.expires_at) + .await + .unwrap(), + Some(first.clone()) + ); + assert!( + repository + .renew_session( + "renewal", + &first, + original.absolute_expires_at.unwrap() + chrono::Duration::seconds(1) + ) + .await + .is_err() + ); + repository.delete_session("renewal").await.unwrap(); + assert!( + repository + .renew_session("renewal", &first, expiry + chrono::Duration::minutes(2)) + .await + .unwrap() + .is_none() + ); + assert!(repository.load_session("renewal").await.unwrap().is_none()); +} + +#[tokio::test] +async fn renewal_updates_redis_ttl_and_rejects_a_key_that_expired_after_loading() { + let Some((repository, mut connection, namespace)) = auth_repository().await else { + return; + }; + let original = AuthSessionRecord { + subject: SessionSubjectRecord::Admin { + admin_user_id: "admin".into(), + credential_fingerprint: "fingerprint".into(), + }, + expires_at: Utc::now() + chrono::Duration::minutes(1), + absolute_expires_at: Some(Utc::now() + chrono::Duration::minutes(10)), + }; + repository + .store_session("expiry-race", &original) + .await + .unwrap(); + let expiry = Utc::now() + chrono::Duration::minutes(5); + let renewed = repository + .renew_session("expiry-race", &original, expiry) + .await + .unwrap() + .unwrap(); + let keys: Vec = redis::cmd("KEYS") + .arg(format!("{namespace}:session:*")) + .query_async(&mut connection) + .await + .unwrap(); + let ttl: i64 = redis::cmd("PTTL") + .arg(&keys[0]) + .query_async(&mut connection) + .await + .unwrap(); + assert!((240_000..=300_000).contains(&ttl)); + redis::cmd("PEXPIRE") + .arg(&keys[0]) + .arg(1) + .query_async::<()>(&mut connection) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + repository + .renew_session( + "expiry-race", + &renewed, + expiry + chrono::Duration::minutes(1) + ) + .await + .unwrap() + .is_none() + ); + assert!( + repository + .load_session("expiry-race") + .await + .unwrap() + .is_none() + ); +} diff --git a/deploy/README.md b/deploy/README.md index 09e6a4de6..4a2a220d3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -144,8 +144,12 @@ openssl rand -hex 24 - `store.redis.password` 另行设置 `admin.default_password`。它至少需要 12 个字符,不能是常见弱口令,也不能包含 `$`。 -`client.session_ttl_minutes` 控制统一登录中密钥身份的固定会话有效期,默认 1440 分钟;管理员有效期仍由 -`admin.session_ttl_minutes` 控制。两种身份共用一个 Cookie,成功登录替换旧会话;不改变 `/v1/*` 鉴权和限额 +管理员的 `admin.session_ttl_minutes` 控制不活动期限,`admin.session_absolute_ttl_minutes` 控制从登录起计算的最长有效期, +后者省略时为 43200 分钟(30 天)。配置模板分别为 10080 分钟(7 天)和 43200 分钟;已有配置保留自己的不活动期限。 +使用管理页面时会自动续期,最终到期时间不超过最长有效期;修改配置需重启,已有会话的最长有效期不会因此延长。 +缺少最长有效期记录的会话保持原有固定期限,重新登录后应用续期策略。 +`client.session_ttl_minutes` 控制密钥身份的固定有效期,默认 1440 分钟,不随活动续期。 +两种身份共用一个 Cookie,成功登录替换当前浏览器的旧会话;不改变 `/v1/*` 鉴权和限额 PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区 引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 6fbc05695..737bc5893 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -56,7 +56,9 @@ store: # acquire_timeout_seconds: 5 admin: - session_ttl_minutes: 1440 + # 不活动期限与从登录起计算的最长有效期,单位为分钟 + session_ttl_minutes: 10080 + session_absolute_ttl_minutes: 43200 default_username: 'admin@cpr.local' # 仅首次创建管理员时使用;至少 12 个字符且不能包含 `$`。 default_password: '' diff --git a/docs/api.md b/docs/api.md index 562003e3a..5ca8f2ae3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -511,11 +511,12 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; | 方法 | 路由 | 请求 | 说明 | | --- | --- | --- | --- | | `POST` | `/api/auth/login` | `{ mode: "admin", username?, password }` 或 `{ mode: "key", apiKey }` | 验证凭据、创建会话;成功后撤销请求携带的旧会话 | -| `GET` | `/api/auth/status` | 无 | 从 Cookie 恢复服务端身份,返回 `{ authenticated, session }` | +| `GET` | `/api/auth/status` | 无 | 只读校验 Cookie 对应身份,返回 `{ authenticated, session }` | +| `POST` | `/api/auth/refresh` | 无 | 校验身份并按管理员策略续期,返回同 status 的结构;有效会话同步设置 Cookie | | `POST` | `/api/auth/logout` | 无 | 删除当前会话并清除 Cookie;存储失败返回 503,不假装退出成功 | | `POST` | `/api/auth/password` | `{ currentPassword, newPassword }` | 仅管理员会话可用;验证当前密码后修改密码,撤销全部管理员会话并清除当前 Cookie | -登录返回 `data: { role: "admin" | "key", expiresAt }`;status 已登录时的 `session` 使用同一结构, +登录返回 `data: { role: "admin" | "key", expiresAt }`;status 和 refresh 已登录时的 `session` 使用同一结构, 未登录时为 `{ authenticated: false, session: null }`。`role` 由服务端已验证身份推导,不接受客户端声明。 不返回凭据或绑定 ID @@ -525,8 +526,10 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; 该入口共用登录尝试限流,超限返回 429。普通设置变更和管理员 API Key 变更不撤销密码登录会话,密钥身份会话也不受改密影响 会话由服务端保存,Cookie 属性为 `Path=/; HttpOnly; SameSite=Lax`,`Max-Age` / -`Expires` 对齐固定有效期,`Secure` 沿用上述 Origin 规则。轮询不会续期。 -管理员有效期由 `admin.session_ttl_minutes` 控制;密钥有效期由 `client.session_ttl_minutes` 控制,默认 1440 分钟 +`Expires` 对齐服务端有效期,`Secure` 沿用上述 Origin 规则。普通业务请求和状态轮询不会续期。 +管理员通过 refresh 在不活动期限内续期,不能超过登录时确定的最长有效期;已过期或撤销的会话不会重新创建。 +refresh 未认证时返回 `{ authenticated: false, session: null }`,不写 Cookie;依赖故障返回 503。 +密钥身份和缺少最长有效期记录的会话保持固定期限。期限配置见[部署配置](../deploy/README.md#手动安装) 每次恢复密钥会话时重新确认 Key 存在且启用;停用或删除后会话失效,重新启用不会恢复已撤销会话。 依赖不可用时返回 503,不返回已认证或假装未登录。预算耗尽不妨碍登录 @@ -539,7 +542,8 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; 拒绝时使用 `42901` 和 `Retry-After` 认证错误共用 `40101`(会话失效)、`40102`(凭据错误)和 `40301`(权限不足)。 -前端只在明确的会话失效时统一退出,不按 URL 或每个接口上的身份标记分发 +前端在 `40101` 后统一尝试一次会话恢复,并发请求等待同一恢复结果;恢复成功后最多重放一次,确认失效才退出。 +`40102` 和 `40301` 不触发恢复,网络或依赖故障不清除身份 ### Key 用量与客户端配置 diff --git a/docs/architecture.md b/docs/architecture.md index 47429b0f7..75e096e61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -296,7 +296,8 @@ Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请 | 入口 | 职责 | | --- | --- | -| `api/modules/`、`api/request.ts` | HTTP 合同、统一请求与错误处理;业务页面不重复解释响应信封 | +| `api/modules/` | 页面 HTTP 合同,模块名对应 `views/` 一级目录;复杂模块在同名目录内按功能拆分,业务类型由所属模块定义 | +| `api/request/` | 请求入口与插件链;负责会话恢复、重试、错误提示、错误归一化和原始响应解析,页面不重复解释响应信封 | | `stores/modules/`、`router/`、`plugins/` | 应用级登录与界面状态、路由和初始化;服务端数据的权威仍在后端 | | `views/<页面>/index.vue`、`views/<页面>/components/` | 页面组合与局部交互;复杂组件按职责拆成目录入口和相邻模块 | | `views/<页面>/composables/` | 页面查询、轮询、表单及操作生命周期 | @@ -513,7 +514,7 @@ Provider 管理适配在仍持有结构化失败事实时选择静态 `public_me 连接测试的 `gateway` / `provider` / `upstream` 来源以及 `not_sent` / `sent` / `ambiguous` 发送状态由 Core 在 仍持有完整执行错误时一次判定;Vue 只能根据稳定字段生成摘要,不能匹配英文错误句子反推来源 -Vue 普通管理请求的错误提示由 `api/request.ts` 请求封装统一负责:优先展示安全信封的 `message`, +Vue 普通管理请求的错误提示由 `api/request/` 请求封装统一负责:优先展示安全信封的 `message`, 缺失或空白时才使用请求层的 HTTP、网络或超时兜底;成功业务码为 `200`,HTTP 成功但业务码失败也会拒绝。 规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误; 查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner @@ -529,7 +530,15 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 请求封装统一负 登录类型只选择凭据校验方式,权限来自服务端保存的身份。管理入口只接受管理员身份或部署级管理 API Key; AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;Key 会话不能访问管理员页面和管理接口。 前端只维护一份 Auth Store,不在每个 API 请求上标记身份;401 会话失效、403 权限不足和 503 依赖故障分别处理。 -成功登录替换旧会话,登出必须确认服务端撤销。 +成功登录替换当前浏览器的旧会话,登出必须确认服务端撤销。 +管理员的不活动期限和最长有效期由 AuthService 持有;显式续期验证当前身份并限制写入频率,Store 原子比较会话后延长期限, +不重新创建已退出或过期的键,API 将返回的有效期同步到 Cookie。Key 与缺少最长有效期记录的会话保持固定期限。 +前端在首次恢复及可见页面的使用行为中请求续期,后台轮询不续期;认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行, +其他环境在当前页面串行。请求层对 `40101` 共用一次会话恢复,恢复期间的新请求等待同一结果; +成功后原请求最多重放一次,取消或身份已切换的请求不重放,确认失效才退出。恢复接口跳过该协调以避免循环等待。 +读取请求和明确幂等的续期请求遇到临时网络或服务故障时有限重试,写操作不默认执行此类重试。 +首次恢复由路由守卫等待校验后放行;临时故障阻止导航,恢复联网或重新聚焦时再次校验,不增加恢复页面。 +已有页面遇到临时故障不清除身份 管理员会话保存由已加盐密码哈希派生的指纹,每次恢复时与 PostgreSQL 当前密码核对;普通设置变更不影响该绑定。 改密在 AuthService 验证当前密码和新密码策略,Store 以旧哈希条件更新密码并在同一 PostgreSQL 事务记录审计。 事务提交后旧管理员会话的指纹失配,不依赖 Redis 批量删除完成撤销;原始密码及密码哈希不进入 Redis。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 65dcfe7e9..ab78e1afe 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,37 +1,26 @@ -// 账号分组 -export * from './modules/account-groups' - // 账号管理 export * from './modules/accounts' -// API Keys 管理 -export * from './modules/api-keys' - -// 认证相关 -export * from './modules/auth' - -// 备份管理 -export * from './modules/backups' - // Dashboard 聚合 export * from './modules/dashboard' +// 账号分组 +export * from './modules/groups' + +// API Keys 管理 +export * from './modules/keys' + +// 登录与会话 +export * from './modules/login' + // 插件管理 -export * from './modules/plugin-extensions' export * from './modules/plugins' -// 模型定价 -export * from './modules/pricing' - +// 代理管理 export * from './modules/proxies' // 设置管理 export * from './modules/settings' -// 系统更新 -export * from './modules/system' - // 使用记录 export * from './modules/usage' - -export type { RequestLocation } from './types/request-location' diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 516958fb3..63c685399 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -1,5 +1,5 @@ import type { RequestOptions } from '../request' -import type { AccountGroupRef } from './account-groups' +import type { AccountGroupRef } from './groups' import request from '../request' export type AccountStatus diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts deleted file mode 100644 index fe56c87e1..000000000 --- a/frontend/src/api/modules/auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import request from '../request' - -export interface AuthSession { - role: 'admin' | 'key' - expiresAt: string -} - -export type LoginParam - = | { mode: 'admin', username: string, password: string } - | { mode: 'key', apiKey: string } - -export interface AuthStatusResponse { - authenticated: boolean - session: AuthSession | null -} - -export interface LogoutResponse { - message: string -} - -export function login(data: LoginParam) { - return request({ - url: '/api/auth/login', - method: 'POST', - data, - }) -} - -export function getAuthStatus() { - return request({ - url: '/api/auth/status', - method: 'GET', - }) -} - -export function logout() { - return request({ - url: '/api/auth/logout', - method: 'POST', - }) -} - -export function changeAdminPassword(data: { currentPassword: string, newPassword: string }) { - return request<{ message: string }>({ - url: '/api/auth/password', - method: 'POST', - data, - }) -} diff --git a/frontend/src/api/modules/account-groups.ts b/frontend/src/api/modules/groups.ts similarity index 100% rename from frontend/src/api/modules/account-groups.ts rename to frontend/src/api/modules/groups.ts diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/keys.ts similarity index 96% rename from frontend/src/api/modules/api-keys.ts rename to frontend/src/api/modules/keys.ts index 57124641c..6240fb9ff 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/keys.ts @@ -1,6 +1,6 @@ import type { RequestOptions } from '../request' -import type { AccountGroupRef } from './account-groups' -import type { ClientProfileSelection, ProviderRequestProfile, ProviderRequestProfiles, XaiClientProfileSelection } from './client-profiles' +import type { AccountGroupRef } from './groups' +import type { ClientProfileSelection, ProviderRequestProfile, ProviderRequestProfiles, XaiClientProfileSelection } from './settings/profiles' import request from '../request' export type ApiKeyRoutingScope = 'all' | 'groups' diff --git a/frontend/src/api/modules/login.ts b/frontend/src/api/modules/login.ts new file mode 100644 index 000000000..cf9070519 --- /dev/null +++ b/frontend/src/api/modules/login.ts @@ -0,0 +1,75 @@ +import request from '../request' + +export interface AuthSession { + role: 'admin' | 'key' + expiresAt: string +} + +export type LoginParam + = | { mode: 'admin', username: string, password: string } + | { mode: 'key', apiKey: string } + +export interface AuthStatusResponse { + authenticated: boolean + session: AuthSession | null +} + +export interface LogoutResponse { + message: string +} + +let pendingMutation: Promise = Promise.resolve() + +// Cookie 写入由浏览器完成,跨标签页串行认证操作以免晚到的续期覆盖登录或退出 +function withSessionLock(operation: () => Promise): Promise { + const run = () => navigator.locks + ? navigator.locks.request('cpr-session', operation) + : operation() + const result = pendingMutation.then(run, run) + pendingMutation = result.catch(() => {}) + return result +} + +export function login(data: LoginParam) { + return withSessionLock(() => request({ + url: '/api/auth/login', + method: 'POST', + skipSessionRecovery: true, + data, + })) +} + +export function getAuthStatus() { + return request({ + url: '/api/auth/status', + method: 'GET', + }) +} + +export function refreshAuthSession() { + return withSessionLock(() => request({ + url: '/api/auth/refresh', + method: 'POST', + skipSessionRecovery: true, + retry: true, + silent: true, + timeout: 10_000, + })) +} + +export function logout() { + return withSessionLock(() => request({ + url: '/api/auth/logout', + method: 'POST', + skipSessionRecovery: true, + })) +} + +export function changeAdminPassword(data: { currentPassword: string, newPassword: string }) { + return withSessionLock(() => request<{ message: string }>({ + url: '/api/auth/password', + method: 'POST', + skipSessionRecovery: true, + data, + })) +} diff --git a/frontend/src/api/modules/plugin-extensions.ts b/frontend/src/api/modules/plugins/extensions.ts similarity index 96% rename from frontend/src/api/modules/plugin-extensions.ts rename to frontend/src/api/modules/plugins/extensions.ts index 049e43cd9..d5df37339 100644 --- a/frontend/src/api/modules/plugin-extensions.ts +++ b/frontend/src/api/modules/plugins/extensions.ts @@ -1,6 +1,6 @@ -import type { RequestConfig, RequestOptions } from '../request' -import { API_BASE_URL } from '../constants' -import request, { requestRaw } from '../request' +import type { RequestConfig, RequestOptions } from '../../request' +import { API_BASE_URL } from '../../constants' +import request, { requestRaw } from '../../request' // 管理路由可能执行长任务,HTTP 与页面桥使用同一等待上限。 export const PLUGIN_MANAGEMENT_TIMEOUT_MS = 180_000 diff --git a/frontend/src/api/modules/plugins.ts b/frontend/src/api/modules/plugins/index.ts similarity index 98% rename from frontend/src/api/modules/plugins.ts rename to frontend/src/api/modules/plugins/index.ts index acdcc722d..915be4d6d 100644 --- a/frontend/src/api/modules/plugins.ts +++ b/frontend/src/api/modules/plugins/index.ts @@ -1,7 +1,9 @@ -import type { RequestOptions } from '../request' +import type { RequestOptions } from '../../request' -import { API_BASE_URL } from '../constants' -import request from '../request' +import { API_BASE_URL } from '../../constants' +import request from '../../request' + +export * from './extensions' export interface PluginSourceEgress { id: string diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index 9a029b245..fdb0b5db0 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,6 +1,6 @@ import type { RequestOptions } from '../request' -import type { RequestLocation } from '../types/request-location' -import type { AccountGroupRef } from './account-groups' +import type { AccountGroupRef } from './groups' +import type { RequestLocation } from './settings' import request from '../request' export type ProxyLocationDetection diff --git a/frontend/src/api/modules/backups.ts b/frontend/src/api/modules/settings/backups.ts similarity index 97% rename from frontend/src/api/modules/backups.ts rename to frontend/src/api/modules/settings/backups.ts index e40955b49..2071d1379 100644 --- a/frontend/src/api/modules/backups.ts +++ b/frontend/src/api/modules/settings/backups.ts @@ -1,5 +1,5 @@ -import type { RequestOptions } from '../request' -import request from '../request' +import type { RequestOptions } from '../../request' +import request from '../../request' export interface BackupSettingsView { storageRevision: number diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings/index.ts similarity index 93% rename from frontend/src/api/modules/settings.ts rename to frontend/src/api/modules/settings/index.ts index 73de9c23d..c756c2e91 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings/index.ts @@ -1,7 +1,17 @@ -import type { RequestOptions } from '../request' -import type { RequestLocation } from '../types/request-location' -import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './client-profiles' -import request from '../request' +import type { RequestOptions } from '../../request' +import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './profiles' +import request from '../../request' + +export * from './backups' +export * from './pricing' +export * from './system' + +export interface RequestLocation { + country: string + region: string + city: string + timezone: string +} export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky' diff --git a/frontend/src/api/modules/pricing.ts b/frontend/src/api/modules/settings/pricing.ts similarity index 95% rename from frontend/src/api/modules/pricing.ts rename to frontend/src/api/modules/settings/pricing.ts index 1a92a8cf7..08959cfa6 100644 --- a/frontend/src/api/modules/pricing.ts +++ b/frontend/src/api/modules/settings/pricing.ts @@ -1,5 +1,5 @@ -import type { RequestOptions } from '../request' -import request from '../request' +import type { RequestOptions } from '../../request' +import request from '../../request' export type PriceBand = 'standard' | 'fast' | 'flex' | 'long_standard' | 'long_fast' | 'long_flex' | 'image' diff --git a/frontend/src/api/modules/client-profiles.ts b/frontend/src/api/modules/settings/profiles.ts similarity index 98% rename from frontend/src/api/modules/client-profiles.ts rename to frontend/src/api/modules/settings/profiles.ts index ce4d21f0b..e9d84c755 100644 --- a/frontend/src/api/modules/client-profiles.ts +++ b/frontend/src/api/modules/settings/profiles.ts @@ -1,4 +1,4 @@ -import request from '../request' +import request from '../../request' export type ProviderRequestProfile = Record export type ProviderRequestProfiles = Record diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/settings/system.ts similarity index 97% rename from frontend/src/api/modules/system.ts rename to frontend/src/api/modules/settings/system.ts index b2a5d56fd..8e609aa33 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/settings/system.ts @@ -1,5 +1,5 @@ -import type { RequestOptions } from '../request' -import request from '../request' +import type { RequestOptions } from '../../request' +import request from '../../request' export type SystemUpdateChannel = 'stable' | 'rc' | 'beta' | 'alpha' | 'exp' diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts deleted file mode 100644 index ad49fba49..000000000 --- a/frontend/src/api/request.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { - AxiosInstance, - AxiosRequestConfig, -} from 'axios' - -import { toast } from '@codex-proxy/ui' -import axios from 'axios' -import { API_BASE_URL, API_TIMEOUT_MS } from './constants' -import { ApiError, normalizeApiError, normalizeApiResponseError } from './error' - -export { ApiError } from './error' - -export interface RequestOptions { - // 静默只关闭全局提示,不吞掉异常,也不跳过会话失效处理。 - silent?: boolean - signal?: AbortSignal - timeout?: number -} - -export type RequestConfig = AxiosRequestConfig & RequestOptions - -export interface RawResponseMeta { - status: number - header: (name: string) => string | undefined -} - -export interface RawResponse { - status: number - contentType: string - body: ArrayBuffer -} - -export interface RawResponseOptions { - maximumBytes: number - accept: (response: RawResponseMeta) => boolean -} - -const http: AxiosInstance = axios.create({ - baseURL: API_BASE_URL, - timeout: API_TIMEOUT_MS, - withCredentials: true, -}) - -// 会话失效是后端业务事实;登录凭据错误和 403 不清除已有会话。 -const SESSION_REQUIRED = 40101 -let unauthorizedHandled = false -let sessionGeneration = 0 -let unauthorizedHandler: (() => void | Promise) | undefined - -export function setUnauthorizedHandler(handler: () => void | Promise) { - unauthorizedHandler = handler -} - -export function resetUnauthorizedHandling() { - sessionGeneration += 1 - unauthorizedHandled = false -} - -function handleUnauthorizedOnce() { - if (unauthorizedHandled || !unauthorizedHandler) - return - unauthorizedHandled = true - void Promise.resolve(unauthorizedHandler()).catch(() => { - unauthorizedHandled = false - }) -} - -function rejectRequest(error: ApiError, config: RequestConfig, generation: number) { - if (error.kind === 'cancelled' || config.signal?.aborted || generation !== sessionGeneration) - return Promise.reject(error) - - const sessionExpired = error.status === 401 && error.code === SESSION_REQUIRED - const alreadyHandled = sessionExpired && unauthorizedHandled - if (sessionExpired) - handleUnauthorizedOnce() - if (!config.silent && !alreadyHandled) - toast.error(error.message) - return Promise.reject(error) -} - -interface ApiEnvelope { - code: number - message: string - data: unknown -} - -function isApiEnvelope(value: unknown): value is ApiEnvelope { - return ( - typeof value === 'object' - && value !== null - && 'data' in value - && 'code' in value && typeof value.code === 'number' - && 'message' in value && typeof value.message === 'string' - ) -} - -export default async function request(config: RequestConfig): Promise { - const generation = sessionGeneration - try { - const response = await http.request(config) - const error = normalizeApiResponseError(response) - if (error) - throw error - return (isApiEnvelope(response.data) ? response.data.data : response.data) as T - } - catch (error) { - if (error instanceof ApiError) - return rejectRequest(error, config, generation) - if (axios.isAxiosError(error)) - return rejectRequest(normalizeApiError(error), config, generation) - throw error - } -} - -/** - * 使用同一管理会话请求原始字节。只有调用方识别的隔离响应才会作为业务正文返回; - * 其余响应继续按 Admin 错误信封处理,不绕过会话失效与统一错误提示。 - */ -export async function requestRaw( - config: RequestConfig, - options: RawResponseOptions, -): Promise { - const generation = sessionGeneration - try { - const response = await http.request({ - ...config, - responseType: 'arraybuffer', - validateStatus: () => true, - }) - const body = response.data - const meta: RawResponseMeta = { - status: response.status, - header: name => rawHeader(response.headers, name), - } - if (options.accept(meta)) { - if (body.byteLength > options.maximumBytes) - throw new ApiError('响应内容超过大小限制', 502, undefined, rawHeader(response.headers, 'x-request-id'), 'http') - return { - status: response.status, - contentType: rawHeader(response.headers, 'content-type') ?? 'application/octet-stream', - body, - } - } - - const decoded = decodeRawEnvelope(body) - const normalized = normalizeApiResponseError({ ...response, data: decoded }) - if (normalized) - throw normalized - throw new ApiError( - response.status >= 400 ? `请求失败(HTTP ${response.status})` : '服务返回了不受支持的响应', - response.status, - undefined, - rawHeader(response.headers, 'x-request-id'), - 'http', - ) - } - catch (error) { - if (error instanceof ApiError) - return rejectRequest(error, config, generation) - if (axios.isAxiosError(error)) - return rejectRequest(normalizeApiError(error), config, generation) - throw error - } -} - -function rawHeader(headers: unknown, name: string) { - if (!headers || typeof headers !== 'object') - return undefined - const source = headers as { get?: (key: string) => unknown, [key: string]: unknown } - const value = typeof source.get === 'function' ? source.get(name) : source[name] - return typeof value === 'string' && value ? value : undefined -} - -function decodeRawEnvelope(body: ArrayBuffer): unknown { - if (body.byteLength === 0) - return undefined - try { - return JSON.parse(new TextDecoder().decode(new Uint8Array(body))) as unknown - } - catch { - return undefined - } -} diff --git a/frontend/src/api/request/index.ts b/frontend/src/api/request/index.ts new file mode 100644 index 000000000..a97ab1549 --- /dev/null +++ b/frontend/src/api/request/index.ts @@ -0,0 +1,55 @@ +import type { AxiosRequestConfig } from 'axios' +import type { RequestContext, RequestNext, RequestPlugin } from './plugins' +import type { RawResponse, RawResponseOptions } from './plugins/raw' +import axios from 'axios' +import { API_BASE_URL, API_TIMEOUT_MS } from '../constants' +import { errorPlugin } from './plugins/error' +import { feedbackPlugin } from './plugins/feedback' +import { rawPlugin } from './plugins/raw' +import { retryPlugin } from './plugins/retry' +import { sessionPlugin } from './plugins/session' + +export { ApiError } from './plugins/error' +export type { RawResponse, RawResponseMeta, RawResponseOptions } from './plugins/raw' +export { resetUnauthorizedHandling, setSessionRecoveryHandler, setUnauthorizedHandler } from './plugins/session' + +export interface RequestOptions { + // 静默只关闭提示,不吞掉异常或绕过会话恢复 + silent?: boolean + signal?: AbortSignal + timeout?: number + // 读取默认重试,写操作须由调用方确认幂等后开启 + retry?: boolean + // 认证接口自身跳过会话恢复,避免循环等待 + skipSessionRecovery?: boolean +} + +export type RequestConfig = AxiosRequestConfig & RequestOptions + +const http = axios.create({ + baseURL: API_BASE_URL, + timeout: API_TIMEOUT_MS, + withCredentials: true, +}) + +// 从外到内:提示、会话恢复、重试、错误归一化、原始响应处理、实际传输 +const plugins: RequestPlugin[] = [feedbackPlugin, sessionPlugin, retryPlugin, errorPlugin, rawPlugin] + +function execute(config: RequestConfig, raw?: RawResponseOptions) { + const context: RequestContext = { config, raw, isCurrent: () => !config.signal?.aborted } + const transport: RequestNext = () => http.request(context.config) + return plugins.reduceRight((next, plugin) => () => plugin(context, next), transport)() +} + +export default async function request(config: RequestConfig): Promise { + const { data } = await execute(config) + const envelope = data !== null && typeof data === 'object' && 'data' in data + && 'code' in data && typeof data.code === 'number' + && 'message' in data && typeof data.message === 'string' + return (envelope ? data.data : data) as T +} + +export async function requestRaw(config: RequestConfig, options: RawResponseOptions): Promise { + const { data } = await execute({ retry: false, ...config }, options) + return data as RawResponse +} diff --git a/frontend/src/api/error.ts b/frontend/src/api/request/plugins/error.ts similarity index 80% rename from frontend/src/api/error.ts rename to frontend/src/api/request/plugins/error.ts index dcbfaa995..3de6c8a9e 100644 --- a/frontend/src/api/error.ts +++ b/frontend/src/api/request/plugins/error.ts @@ -1,4 +1,6 @@ import type { AxiosError, AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios' +import type { RequestContext, RequestNext } from './index' +import { isAxiosError } from 'axios' export type ApiErrorKind = 'api' | 'timeout' | 'network' | 'http' | 'cancelled' @@ -7,6 +9,19 @@ interface AdminErrorEnvelope { message: string } +export async function errorPlugin(_context: RequestContext, next: RequestNext) { + try { + const response = await next() + const error = normalizeApiResponseError(response) + if (error) + throw error + return response + } + catch (error) { + throw isAxiosError(error) ? normalizeApiError(error) : error + } +} + export class ApiError extends Error { constructor( message: string, @@ -20,6 +35,11 @@ export class ApiError extends Error { } } +export function isTransientApiError(error: unknown): error is ApiError { + return error instanceof ApiError + && (error.kind === 'network' || error.kind === 'timeout' || [502, 503, 504].includes(error.status)) +} + export function normalizeApiError(error: AxiosError) { const status = error.response?.status ?? 0 const requestId = responseHeader(error.response?.headers, 'x-request-id') @@ -106,3 +126,7 @@ function httpFallback(status: number) { return status > 0 ? `请求失败(HTTP ${status})` : '请求失败' } } + +export function isSessionRequired(error: unknown): error is ApiError & { status: 401, code: 40101 } { + return error instanceof ApiError && error.status === 401 && error.code === 40101 +} diff --git a/frontend/src/api/request/plugins/feedback.ts b/frontend/src/api/request/plugins/feedback.ts new file mode 100644 index 000000000..108254bcc --- /dev/null +++ b/frontend/src/api/request/plugins/feedback.ts @@ -0,0 +1,20 @@ +import type { RequestContext, RequestNext } from './index' +import { toast } from '@codex-proxy/ui' +import { ApiError, isSessionRequired } from './error' + +// 共享刷新 Promise 的同一个失败只提示一次,弱引用不保留已结束请求 +const reported = new WeakSet() + +export async function feedbackPlugin(context: RequestContext, next: RequestNext) { + try { + return await next() + } + catch (error) { + if (error instanceof ApiError && error.kind !== 'cancelled' && !isSessionRequired(error) + && !context.config.silent && context.isCurrent() && !reported.has(error)) { + reported.add(error) + toast.error(error.message) + } + throw error + } +} diff --git a/frontend/src/api/request/plugins/index.ts b/frontend/src/api/request/plugins/index.ts new file mode 100644 index 000000000..5a9201630 --- /dev/null +++ b/frontend/src/api/request/plugins/index.ts @@ -0,0 +1,14 @@ +import type { AxiosResponse } from 'axios' +import type { RequestConfig } from '../index' +import type { RawResponseOptions } from './raw' + +export interface RequestContext { + config: RequestConfig + raw?: RawResponseOptions + // 会话插件补充身份代际检查,重试与提示消费同一请求有效性 + isCurrent: () => boolean +} + +export type RequestNext = () => Promise> + +export type RequestPlugin = (context: RequestContext, next: RequestNext) => Promise> diff --git a/frontend/src/api/request/plugins/raw.ts b/frontend/src/api/request/plugins/raw.ts new file mode 100644 index 000000000..6e6d2ac0f --- /dev/null +++ b/frontend/src/api/request/plugins/raw.ts @@ -0,0 +1,78 @@ +import type { RequestContext, RequestNext } from './index' +import { ApiError, normalizeApiResponseError } from './error' + +export interface RawResponseMeta { + status: number + header: (name: string) => string | undefined +} + +export interface RawResponse { + status: number + contentType: string + body: ArrayBuffer +} + +export interface RawResponseOptions { + maximumBytes: number + accept: (response: RawResponseMeta) => boolean +} + +export async function rawPlugin(context: RequestContext, next: RequestNext) { + const options = context.raw + if (!options) + return next() + context.config = { + ...context.config, + responseType: 'arraybuffer', + validateStatus: () => true, + } + const response = await next() + const body = response.data as ArrayBuffer + const meta: RawResponseMeta = { + status: response.status, + header: name => rawHeader(response.headers, name), + } + if (options.accept(meta)) { + if (body.byteLength > options.maximumBytes) + throw new ApiError('响应内容超过大小限制', 502, undefined, rawHeader(response.headers, 'x-request-id'), 'http') + return { + ...response, + data: { + status: response.status, + contentType: rawHeader(response.headers, 'content-type') ?? 'application/octet-stream', + body, + } satisfies RawResponse, + } + } + + const decoded = decodeRawEnvelope(body) + const normalized = normalizeApiResponseError({ ...response, data: decoded }) + if (normalized) + throw normalized + throw new ApiError( + response.status >= 400 ? `请求失败(HTTP ${response.status})` : '服务返回了不受支持的响应', + response.status, + undefined, + rawHeader(response.headers, 'x-request-id'), + 'http', + ) +} + +function rawHeader(headers: unknown, name: string) { + if (!headers || typeof headers !== 'object') + return undefined + const source = headers as { get?: (key: string) => unknown, [key: string]: unknown } + const value = typeof source.get === 'function' ? source.get(name) : source[name] + return typeof value === 'string' && value ? value : undefined +} + +function decodeRawEnvelope(body: ArrayBuffer): unknown { + if (body.byteLength === 0) + return undefined + try { + return JSON.parse(new TextDecoder().decode(new Uint8Array(body))) as unknown + } + catch { + return undefined + } +} diff --git a/frontend/src/api/request/plugins/retry.ts b/frontend/src/api/request/plugins/retry.ts new file mode 100644 index 000000000..c3c4c0384 --- /dev/null +++ b/frontend/src/api/request/plugins/retry.ts @@ -0,0 +1,28 @@ +import type { RequestContext, RequestNext } from './index' +import { ApiError, isTransientApiError } from './error' + +export async function retryPlugin(context: RequestContext, next: RequestNext) { + const { config } = context + const retry = config.retry ?? ['GET', 'HEAD'].includes((config.method ?? 'GET').toUpperCase()) + for (let attempt = 0; ; attempt += 1) { + if (!context.isCurrent()) + throw new ApiError('请求已取消', 0, undefined, undefined, 'cancelled') + try { + return await next() + } + catch (error) { + if (!retry || attempt >= 2 || !isTransientApiError(error) || !context.isCurrent()) + throw error + await new Promise((resolve) => { + let timer: ReturnType + const finish = () => { + clearTimeout(timer) + config.signal?.removeEventListener('abort', finish) + resolve() + } + timer = setTimeout(finish, 500 * 2 ** attempt) + config.signal?.addEventListener('abort', finish, { once: true }) + }) + } + } +} diff --git a/frontend/src/api/request/plugins/session.ts b/frontend/src/api/request/plugins/session.ts new file mode 100644 index 000000000..47b44c062 --- /dev/null +++ b/frontend/src/api/request/plugins/session.ts @@ -0,0 +1,94 @@ +import type { RequestConfig } from '../index' +import type { RequestContext, RequestNext } from './index' +import { toast } from '@codex-proxy/ui' +import { ApiError, isSessionRequired } from './error' + +// 会话失效是后端业务事实,登录凭据错误和 403 不清除已有会话 +const SESSION_REQUIRED = 40101 +let unauthorizedHandled = false +let sessionGeneration = 0 +let unauthorizedHandler: (() => void | Promise) | undefined +let sessionRecoveryHandler: (() => Promise) | undefined +let sessionRecovery: Promise | undefined +let successfulRecoveries = 0 + +export function setSessionRecoveryHandler(handler: () => Promise) { + sessionRecoveryHandler = handler +} + +export function setUnauthorizedHandler(handler: () => void | Promise) { + unauthorizedHandler = handler +} + +export function resetUnauthorizedHandling() { + sessionGeneration += 1 + unauthorizedHandled = false +} + +function handleUnauthorizedOnce() { + if (unauthorizedHandled || !unauthorizedHandler) + return + unauthorizedHandled = true + void Promise.resolve(unauthorizedHandler()).catch(() => { + unauthorizedHandled = false + }) +} + +function recoverSession(): Promise { + if (sessionRecovery) + return sessionRecovery + const recovery = sessionRecoveryHandler!().then((authenticated) => { + if (authenticated) + successfulRecoveries += 1 + return authenticated + }).finally(() => { + if (sessionRecovery === recovery) + sessionRecovery = undefined + }) + sessionRecovery = recovery + return recovery +} + +function expireSession(error: ApiError, config: RequestConfig, generation: number): never { + if (!config.signal?.aborted && generation === sessionGeneration) { + const alreadyHandled = unauthorizedHandled + handleUnauthorizedOnce() + if (!config.silent && !alreadyHandled) + toast.error(error.message) + } + throw error +} + +export async function sessionPlugin(context: RequestContext, send: RequestNext) { + const { config } = context + const generation = sessionGeneration + const isCurrent = context.isCurrent + context.isCurrent = () => isCurrent() && generation === sessionGeneration + if (!config.skipSessionRecovery && sessionRecovery && !await sessionRecovery) + return expireSession(new ApiError('登录已失效,请重新登录', 401, SESSION_REQUIRED), config, generation) + if (config.signal?.aborted || generation !== sessionGeneration) + throw new ApiError('请求已取消', 0, undefined, undefined, 'cancelled') + const recovered = successfulRecoveries + try { + return await send() + } + catch (error) { + if (!isSessionRequired(error) || config.signal?.aborted || generation !== sessionGeneration) + throw error + if (!config.skipSessionRecovery && sessionRecoveryHandler + && (successfulRecoveries !== recovered || await recoverSession())) { + // 40101 表示认证阶段拒绝;恢复成功后原请求最多重放一次 + if (config.signal?.aborted || generation !== sessionGeneration) + throw error + try { + return await send() + } + catch (retryError) { + if (!isSessionRequired(retryError)) + throw retryError + return expireSession(retryError, config, generation) + } + } + return expireSession(error, config, generation) + } +} diff --git a/frontend/src/api/types/request-location.ts b/frontend/src/api/types/request-location.ts deleted file mode 100644 index 9bad392d8..000000000 --- a/frontend/src/api/types/request-location.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface RequestLocation { - country: string - region: string - city: string - timezone: string -} diff --git a/frontend/src/components/client-profile/ClientProfileEditor.vue b/frontend/src/components/client-profile/ClientProfileEditor.vue index 298ecaa65..6be724fdc 100644 --- a/frontend/src/components/client-profile/ClientProfileEditor.vue +++ b/frontend/src/components/client-profile/ClientProfileEditor.vue @@ -3,10 +3,10 @@ import type { ClientProfileOptions, ClientProfilePreview, ClientProfileSelection, -} from '@/api/modules/client-profiles' +} from '@/api/modules/settings/profiles' import { BaseButton, BaseSegmented } from '@codex-proxy/ui' import { computed, onMounted, shallowRef, watch } from 'vue' -import { getClientProfileOptions, previewClientProfile } from '@/api/modules/client-profiles' +import { getClientProfileOptions, previewClientProfile } from '@/api/modules/settings/profiles' import { errorMessage } from '@/utils/operation' import ClientProfilePresetFields from './ClientProfilePresetFields.vue' import ClientProfilePreviewPanel from './ClientProfilePreviewPanel.vue' diff --git a/frontend/src/components/client-profile/ClientProfilePresetFields.vue b/frontend/src/components/client-profile/ClientProfilePresetFields.vue index 88dfc6835..5ec7988f6 100644 --- a/frontend/src/components/client-profile/ClientProfilePresetFields.vue +++ b/frontend/src/components/client-profile/ClientProfilePresetFields.vue @@ -1,5 +1,5 @@