From 286031a441de9f8b4784da0316f85c78431bd296 Mon Sep 17 00:00:00 2001 From: Zyy <571092077@qq.com> Date: Wed, 14 Oct 2026 11:42:33 +0800 Subject: [PATCH] fix(auth): separate access and refresh session credentials (#426) Recover expired admin access credentials through a rotating refresh cookie, preserve concurrent retry semantics, and keep normal requests read-only. Co-authored-by: Zyy Co-authored-by: Codex --- backend/Cargo.lock | 1 + backend/crates/gateway-admin/Cargo.toml | 1 + backend/crates/gateway-admin/src/lib.rs | 4 +- .../crates/gateway-admin/src/model/auth.rs | 44 ++- .../crates/gateway-admin/src/ports/store.rs | 6 +- .../gateway-admin/src/use_case/auth/mod.rs | 139 +++++++++ .../src/use_case/{auth.rs => auth/service.rs} | 236 ++++++--------- .../src/use_case/auth/session.rs | 214 ++++++++++++++ .../gateway-admin/tests/backup/support.rs | 4 +- .../tests/use_case/{auth.rs => auth/mod.rs} | 157 +++------- .../tests/use_case/auth/session.rs | 268 ++++++++++++++++++ .../gateway-admin/tests/use_case/auth_key.rs | 13 +- .../gateway-admin/tests/use_case/mod.rs | 4 +- backend/crates/gateway-api/src/auth.rs | 107 ++++--- .../crates/gateway-api/src/session_cookie.rs | 15 +- backend/crates/gateway-api/tests/admin/mod.rs | 16 +- backend/crates/gateway-api/tests/auth.rs | 209 ++++++++++---- .../crates/gateway-store/src/admin_adapter.rs | 10 +- .../crates/gateway-store/src/redis/auth.rs | 54 ++-- .../crates/gateway-store/tests/redis/auth.rs | 116 ++++---- deploy/README.md | 7 +- docs/api.md | 23 +- docs/architecture.md | 8 +- frontend/src/stores/modules/auth.ts | 5 +- frontend/vite.config.ts | 1 + 25 files changed, 1196 insertions(+), 466 deletions(-) create mode 100644 backend/crates/gateway-admin/src/use_case/auth/mod.rs rename backend/crates/gateway-admin/src/use_case/{auth.rs => auth/service.rs} (67%) create mode 100644 backend/crates/gateway-admin/src/use_case/auth/session.rs rename backend/crates/gateway-admin/tests/use_case/{auth.rs => auth/mod.rs} (76%) create mode 100644 backend/crates/gateway-admin/tests/use_case/auth/session.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b6fdac3ef..b07f0d0f2 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "gateway-core", "heck", "hex", + "hkdf", "prettyplease", "quote", "rand_core 0.6.4", diff --git a/backend/crates/gateway-admin/Cargo.toml b/backend/crates/gateway-admin/Cargo.toml index f62f250b9..09ce322b3 100644 --- a/backend/crates/gateway-admin/Cargo.toml +++ b/backend/crates/gateway-admin/Cargo.toml @@ -21,6 +21,7 @@ futures = "0.3.32" gateway-core = { path = "../gateway-core" } heck = "0.5.0" hex = "0.4.3" +hkdf = "0.13.0" rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 32973c13c..81a668d5a 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -405,9 +405,7 @@ async fn initialize_inner( let registry = providers; let auth = Arc::new(DefaultAuthService::new( - config.default_username, - config.session_ttl_minutes, - config.session_absolute_ttl_minutes, + &config, 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 45b51b0dc..18468542e 100644 --- a/backend/crates/gateway-admin/src/model/auth.rs +++ b/backend/crates/gateway-admin/src/model/auth.rs @@ -76,18 +76,23 @@ impl std::fmt::Debug for LoginCommand { } } -/// 登录成功后返回的会话事实 +/// 登录或刷新后仅向当前浏览器交付的会话凭据 #[derive(Clone, PartialEq, Eq)] -pub struct LoginResult { +pub struct SessionGrant { pub session_id: String, + pub refresh_token: Option, pub session: AuthSession, } -impl std::fmt::Debug for LoginResult { +impl std::fmt::Debug for SessionGrant { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter - .debug_struct("LoginResult") + .debug_struct("SessionGrant") .field("session_id", &"[REDACTED]") + .field( + "refresh_token", + &self.refresh_token.as_ref().map(|_| "[REDACTED]"), + ) .field("session", &self.session) .finish() } @@ -138,12 +143,41 @@ 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>, + pub renewal: Option, +} + +/// 长效登录的凭据摘要与续期窗口;访问到期不删除整段登录 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionRenewal { + pub expires_at: DateTime, + pub access_digest: [u8; 32], + pub refresh_digest: [u8; 32], + pub previous: Option, +} + +/// 仅保存上一代摘要及派生盐;响应丢失时须持有上一代凭据才能重建同一响应 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionRefreshReplay { + pub digest: [u8; 32], + pub salt: [u8; 32], + pub expires_at: DateTime, +} + +impl AuthSession { + #[must_use] + pub fn retained_until(&self) -> DateTime { + self.renewal + .as_ref() + .map_or(self.expires_at, |renewal| renewal.expires_at) + } } /// 安全审计事件类型 diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 4476a1ef0..35fbae99d 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -294,12 +294,12 @@ pub trait AuthStore: Send + Sync { async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>; - /// 只延长仍存在且匹配的会话;并发续期返回当前值,已退出或过期时不重建 - async fn renew_session( + /// 原子替换或撤销仍匹配的会话并返回当前值;过期或退出后不重新创建 + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSession, - expires_at: chrono::DateTime, + replacement: Option<&AuthSession>, ) -> AdminStoreResult>; async fn delete_session(&self, session_id: &str) -> AdminStoreResult>; diff --git a/backend/crates/gateway-admin/src/use_case/auth/mod.rs b/backend/crates/gateway-admin/src/use_case/auth/mod.rs new file mode 100644 index 000000000..e83f4afaa --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/auth/mod.rs @@ -0,0 +1,139 @@ +//! 控制面认证端口、会话策略与访问和续期共用的身份校验 + +use std::{net::IpAddr, sync::Arc}; + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{Duration, Utc}; +use gateway_core::engine::execution::ClientKeyVerifier; +use sha2::{Digest as _, Sha256}; + +use super::map_store_error; +use crate::{ + model::{ + AdminError, + auth::{ + AuthSession, ChangePassword, LoginCommand, LoginError, SessionGrant, SessionSubject, + }, + }, + ports::store::AuthStore, +}; + +mod service; +mod session; + +/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定 +#[async_trait] +pub trait AuthService: Send + Sync { + async fn change_password( + &self, + session_id: Option<&str>, + command: ChangePassword, + source_ip: IpAddr, + ) -> 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, + refresh_token: Option<&str>, + access_token: Option<&str>, + ) -> Result, AdminError>; + async fn resolve_admin_user_id( + &self, + session_id: Option<&str>, + ) -> Result, AdminError>; + async fn verify_admin_api_key(&self, key: &str) -> Result; + async fn login( + &self, + command: LoginCommand, + source_ip: IpAddr, + previous_session_id: Option<&str>, + ) -> Result; + async fn logout(&self, session_id: &str) -> Result<(), AdminError>; +} + +const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60; + +pub(crate) struct DefaultAuthService { + default_admin_user_id: String, + admin_access_ttl: Duration, + admin_session_ttl: Duration, + admin_session_absolute_ttl: Duration, + key_session_ttl: Duration, + store: Arc, + verifier: Arc, +} + +impl DefaultAuthService { + #[must_use] + pub(crate) fn new( + config: &crate::AdminConfig, + key_session_ttl_minutes: u64, + store: Arc, + verifier: Arc, + ) -> Self { + let admin_session_ttl = session_ttl(config.session_ttl_minutes); + Self { + default_admin_user_id: config.default_username.clone(), + // 访问凭据最多有效 24 小时,较短的不活动期限也要留出续期窗口 + admin_access_ttl: Duration::hours(24).min(admin_session_ttl / 4), + admin_session_ttl, + admin_session_absolute_ttl: session_ttl(config.session_absolute_ttl_minutes), + key_session_ttl: session_ttl(key_session_ttl_minutes), + store, + verifier, + } + } + + async fn load_live_session(&self, id: &str) -> Result, AdminError> { + let Some(session) = self + .store + .load_session(id) + .await + .map_err(|error| map_store_error(error, "authentication session"))? + else { + return Ok(None); + }; + if session.retained_until() <= Utc::now() + || session + .absolute_expires_at + .is_some_and(|expiry| expiry <= Utc::now()) + { + return Ok(None); + } + let valid = match &session.subject { + SessionSubject::Admin { + admin_user_id, + credential_fingerprint, + } => self + .store + .load_password_hash(admin_user_id) + .await + .map_err(|error| map_store_error(error, "administrator session"))? + .is_some_and(|hash| password_fingerprint(&hash) == *credential_fingerprint), + SessionSubject::Key { client_key_id } => self + .store + .client_key_enabled(client_key_id) + .await + .map_err(|error| map_store_error(error, "session key"))?, + }; + if !valid { + let _ = self.store.delete_session(id).await; + return Ok(None); + } + Ok(Some(session)) + } +} + +fn session_ttl(minutes: u64) -> Duration { + Duration::minutes( + i64::try_from(minutes) + .unwrap_or(MAX_SESSION_TTL_MINUTES) + .clamp(1, MAX_SESSION_TTL_MINUTES), + ) +} + +// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话 +fn password_fingerprint(password_hash: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes())) +} diff --git a/backend/crates/gateway-admin/src/use_case/auth.rs b/backend/crates/gateway-admin/src/use_case/auth/service.rs similarity index 67% rename from backend/crates/gateway-admin/src/use_case/auth.rs rename to backend/crates/gateway-admin/src/use_case/auth/service.rs index 334b5763a..3d8392ce3 100644 --- a/backend/crates/gateway-admin/src/use_case/auth.rs +++ b/backend/crates/gateway-admin/src/use_case/auth/service.rs @@ -1,93 +1,35 @@ -//! 控制面登录、会话恢复与身份校验的唯一 owner +//! 管理员和 Key 的登录、退出、密码策略与会话访问 -use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration}; +use std::{net::IpAddr, time::Duration as StdDuration}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{Duration, Utc}; -use gateway_core::engine::execution::ClientKeyVerifier; +use chrono::Utc; use rand_core::{OsRng, RngCore as _}; -use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq as _; use uuid::Uuid; +use super::{ + AuthService, DefaultAuthService, password_fingerprint, + session::{self, Credential, storage_id}, +}; use crate::{ model::{ AdminError, AdminErrorKind, auth::{ AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError, - LoginResult, SessionSubject, + SessionGrant, SessionSubject, }, }, - ports::store::AuthStore, + use_case::map_store_error, }; -use super::map_store_error; - -/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定 -#[async_trait] -pub trait AuthService: Send + Sync { - async fn change_password( - &self, - session_id: Option<&str>, - command: ChangePassword, - source_ip: IpAddr, - ) -> 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>, - ) -> Result, AdminError>; - async fn verify_admin_api_key(&self, key: &str) -> Result; - async fn login( - &self, - command: LoginCommand, - source_ip: IpAddr, - previous_session_id: Option<&str>, - ) -> Result; - async fn logout(&self, session_id: &str) -> Result<(), AdminError>; -} - -const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60; const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60); const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10; 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, -} - impl DefaultAuthService { - #[must_use] - 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, - ) -> Self { - 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, - } - } - fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent { AdminAuditEvent { id: format!("audit_{}", Uuid::now_v7().simple()), @@ -228,50 +170,23 @@ impl AuthService for DefaultAuthService { } async fn session(&self, session_id: Option<&str>) -> Result, AdminError> { - let Some(session_id) = session_id.filter(|value| !value.is_empty()) else { + let Some(raw) = session_id.filter(|value| !value.is_empty()) else { return Ok(None); }; - let Some(session) = self - .store - .load_session(session_id) - .await - .map_err(|error| map_store_error(error, "authentication session"))? - else { + let Some(session) = self.load_live_session(storage_id(raw)).await? else { return Ok(None); }; - if session.expires_at <= Utc::now() - || session - .absolute_expires_at - .is_some_and(|expiry| expiry <= Utc::now()) - { - // Redis 的 TTL 负责过期清理,避免旧读取删除另一个请求刚续期的会话 + if session.expires_at <= Utc::now() { return Ok(None); } - if let SessionSubject::Admin { - admin_user_id, - credential_fingerprint, - } = &session.subject - { - let password_hash = self - .store - .load_password_hash(admin_user_id) - .await - .map_err(|error| map_store_error(error, "administrator session"))?; - if password_hash - .is_none_or(|hash| password_fingerprint(&hash) != *credential_fingerprint) - { - let _ = self.store.delete_session(session_id).await; + if let Some(renewal) = &session.renewal { + let Some(credential) = Credential::parse(raw).filter(|value| !value.refresh) else { + return Ok(None); + }; + if !credential.owns(renewal) { return Ok(None); } - } - if let SessionSubject::Key { client_key_id } = &session.subject - && !self - .store - .client_key_enabled(client_key_id) - .await - .map_err(|error| map_store_error(error, "session key"))? - { - let _ = self.store.delete_session(session_id).await; + } else if Credential::parse(raw).is_some() { return Ok(None); } Ok(Some(session)) @@ -279,33 +194,25 @@ impl AuthService for DefaultAuthService { 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)); + refresh_token: Option<&str>, + access_token: Option<&str>, + ) -> Result, AdminError> { + if let Some(refresh) = refresh_token { + return self.refresh(refresh).await; } - self.store - .renew_session(session_id, &session, expires_at) - .await - .map_err(|error| map_store_error(error, "authentication session renewal")) + // Key 与升级前的单凭据仅返回仍有效的原会话,不授予新的续期能力 + let Some(session) = self + .session(access_token) + .await? + .filter(|session| session.renewal.is_none()) + else { + return Ok(None); + }; + Ok(Some(SessionGrant { + session_id: access_token.unwrap_or_default().to_owned(), + refresh_token: None, + session, + })) } async fn resolve_admin_user_id( @@ -346,7 +253,7 @@ impl AuthService for DefaultAuthService { command: LoginCommand, source_ip: IpAddr, previous_session_id: Option<&str>, - ) -> Result { + ) -> Result { if let Some(retry_after) = self .store .consume_login_attempt( @@ -370,14 +277,31 @@ impl AuthService for DefaultAuthService { 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: (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl)), - absolute_expires_at, + let refresh_expiry = (now + ttl).min(absolute_expires_at.unwrap_or(now + ttl)); + let grant = if let Some(absolute) = absolute_expires_at { + session::issue( + subject, + (now + self.admin_access_ttl).min(refresh_expiry), + refresh_expiry, + absolute, + ) + .map_err(|_| LoginError::Unavailable)? + } else { + SessionGrant { + session_id: random_session_token(), + refresh_token: None, + session: AuthSession { + subject, + expires_at: refresh_expiry, + absolute_expires_at: None, + renewal: None, + }, + } }; - let session_id = random_session_token(); + let session_id = storage_id(&grant.session_id); + let session = &grant.session; self.store - .store_session(&session_id, &session) + .store_session(session_id, session) .await .map_err(|_| LoginError::Unavailable)?; if let SessionSubject::Admin { admin_user_id, .. } = &session.subject @@ -387,26 +311,39 @@ impl AuthService for DefaultAuthService { .await .is_err() { - let _ = self.store.delete_session(&session_id).await; + let _ = self.store.delete_session(session_id).await; return Err(LoginError::Unavailable); } // 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话 if let Some(previous) = previous_session_id.filter(|value| !value.is_empty()) && self.logout(previous).await.is_err() { - let _ = self.store.delete_session(&session_id).await; + let _ = self.store.delete_session(session_id).await; return Err(LoginError::Unavailable); } - Ok(LoginResult { - session_id, - session, - }) + Ok(grant) } async fn logout(&self, session_id: &str) -> Result<(), AdminError> { + let id = storage_id(session_id); + let Some(stored) = self + .store + .load_session(id) + .await + .map_err(|error| map_store_error(error, "authentication session"))? + else { + return Ok(()); + }; + if let Some(renewal) = &stored.renewal { + if !Credential::parse(session_id).is_some_and(|credential| credential.owns(renewal)) { + return Ok(()); + } + } else if Credential::parse(session_id).is_some() { + return Ok(()); + } let session = self .store - .delete_session(session_id) + .delete_session(id) .await .map_err(|error| map_store_error(error, "authentication session"))?; if let Some(AuthSession { @@ -423,14 +360,6 @@ impl AuthService for DefaultAuthService { } } -fn session_ttl(minutes: u64) -> Duration { - Duration::minutes( - i64::try_from(minutes) - .unwrap_or(MAX_SESSION_TTL_MINUTES) - .clamp(1, MAX_SESSION_TTL_MINUTES), - ) -} - fn validate_new_password(password: &str) -> Result<(), AdminError> { if password.trim().chars().count() < 12 || password.len() > 1024 @@ -470,8 +399,3 @@ fn valid_admin_api_key_shape(value: &str) -> bool { && value.starts_with("admin-") && value[6..].bytes().all(|byte| byte.is_ascii_hexdigit()) } - -// 指纹只绑定已加盐的密码哈希,不把密码或原始哈希复制到 Redis 会话 -fn password_fingerprint(password_hash: &str) -> String { - URL_SAFE_NO_PAD.encode(Sha256::digest(password_hash.as_bytes())) -} diff --git a/backend/crates/gateway-admin/src/use_case/auth/session.rs b/backend/crates/gateway-admin/src/use_case/auth/session.rs new file mode 100644 index 000000000..53afca6c5 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/auth/session.rs @@ -0,0 +1,214 @@ +//! 双凭据会话的签发、摘要校验与短窗口重试,不持久化可直接使用的凭据 + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; +use rand_core::{OsRng, RngCore as _}; +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; + +use super::DefaultAuthService; +use crate::{ + model::{ + AdminError, AdminErrorKind, + auth::{AuthSession, SessionGrant, SessionRefreshReplay, SessionRenewal, SessionSubject}, + }, + use_case::map_store_error, +}; + +const REPLAY_WINDOW: Duration = Duration::seconds(30); + +pub(super) struct Credential<'a> { + pub id: &'a str, + pub secret: &'a str, + pub refresh: bool, +} + +impl<'a> Credential<'a> { + pub fn parse(value: &'a str) -> Option { + let mut parts = value.split('.'); + let refresh = match parts.next()? { + "a2" => false, + "r2" => true, + _ => return None, + }; + let id = parts.next()?; + let secret = parts.next()?; + if parts.next().is_some() || !valid_secret(id) || !valid_secret(secret) { + return None; + } + Some(Self { + id, + secret, + refresh, + }) + } + + pub fn digest(&self) -> [u8; 32] { + digest(self.secret) + } + + pub fn owns(&self, renewal: &SessionRenewal) -> bool { + if self.refresh { + matches_digest(&renewal.refresh_digest, &self.digest()) + || renewal + .previous + .as_ref() + .is_some_and(|previous| matches_digest(&previous.digest, &self.digest())) + } else { + matches_digest(&renewal.access_digest, &self.digest()) + } + } +} + +fn valid_secret(value: &str) -> bool { + value.len() == 43 + && URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|bytes| bytes.len() == 32) +} + +pub(super) fn storage_id(value: &str) -> &str { + Credential::parse(value).map_or(value, |credential| credential.id) +} + +fn matches_digest(left: &[u8; 32], right: &[u8; 32]) -> bool { + bool::from(left.ct_eq(right)) +} + +fn digest(secret: &str) -> [u8; 32] { + Sha256::digest(secret.as_bytes()).into() +} + +fn random_bytes() -> [u8; 32] { + let mut bytes = [0; 32]; + OsRng.fill_bytes(&mut bytes); + bytes +} + +// 使用标准 HKDF 的独立用途标签;只有盐和摘要留在 Store,重试仍须持有原始凭据 +fn derive(secret: &str, salt: Option<&[u8]>, purpose: &[u8]) -> Result { + let mut output = [0; 32]; + hkdf::Hkdf::::new(salt, secret.as_bytes()) + .expand(purpose, &mut output) + .map_err(|_| AdminError::internal("会话凭据派生失败"))?; + Ok(URL_SAFE_NO_PAD.encode(output)) +} + +fn access_secret(refresh: &str) -> Result { + derive(refresh, None, b"cpr-session-v2/access") +} + +pub(super) fn issue( + subject: SessionSubject, + access_expiry: DateTime, + refresh_expiry: DateTime, + absolute: DateTime, +) -> Result { + let id = URL_SAFE_NO_PAD.encode(random_bytes()); + let secret = URL_SAFE_NO_PAD.encode(random_bytes()); + let session = AuthSession { + subject, + expires_at: access_expiry, + absolute_expires_at: Some(absolute), + renewal: Some(SessionRenewal { + expires_at: refresh_expiry, + access_digest: digest(&access_secret(&secret)?), + refresh_digest: digest(&secret), + previous: None, + }), + }; + grant(&id, &secret, session) +} + +fn grant(id: &str, refresh: &str, session: AuthSession) -> Result { + Ok(SessionGrant { + session_id: format!("a2.{id}.{}", access_secret(refresh)?), + refresh_token: Some(format!("r2.{id}.{refresh}")), + session, + }) +} + +impl DefaultAuthService { + pub(super) async fn refresh(&self, raw: &str) -> Result, AdminError> { + let Some(credential) = Credential::parse(raw).filter(|value| value.refresh) else { + return Ok(None); + }; + let mut current = self.load_live_session(credential.id).await?; + // CAS 冲突只重新解释同一请求的凭据,不对并发退出进行补建 + for _ in 0..3 { + let Some(session) = current else { + return Ok(None); + }; + let Some(renewal) = &session.renewal else { + return Ok(None); + }; + let now = Utc::now(); + if session.retained_until() <= now + || session.absolute_expires_at.is_none_or(|limit| limit <= now) + { + return Ok(None); + } + if matches_digest(&renewal.refresh_digest, &credential.digest()) { + // 其他标签页已经刷新或客户端重试时复用当前代,避免 Cookie 响应相互覆盖 + if session.expires_at > now { + return grant(credential.id, credential.secret, session).map(Some); + } + let absolute = session + .absolute_expires_at + .ok_or_else(|| AdminError::internal("缺少会话最长有效期"))?; + let salt = random_bytes(); + let next_secret = + derive(credential.secret, Some(&salt), b"cpr-session-v2/refresh")?; + let refresh_expiry = (now + self.admin_session_ttl).min(absolute); + let access_expiry = (now + self.admin_access_ttl).min(refresh_expiry); + let replacement = AuthSession { + expires_at: access_expiry, + renewal: Some(SessionRenewal { + expires_at: refresh_expiry, + access_digest: digest(&access_secret(&next_secret)?), + refresh_digest: digest(&next_secret), + previous: Some(SessionRefreshReplay { + digest: credential.digest(), + salt, + expires_at: (now + REPLAY_WINDOW).min(access_expiry), + }), + }), + ..session.clone() + }; + current = self + .store + .compare_exchange_session(credential.id, &session, Some(&replacement)) + .await + .map_err(|error| map_store_error(error, "authentication session rotation"))?; + if current.as_ref() == Some(&replacement) { + return grant(credential.id, &next_secret, replacement).map(Some); + } + } else if let Some(previous) = renewal + .previous + .as_ref() + .filter(|previous| matches_digest(&previous.digest, &credential.digest())) + { + if previous.expires_at > now { + let secret = derive( + credential.secret, + Some(&previous.salt), + b"cpr-session-v2/refresh", + )?; + return grant(credential.id, &secret, session).map(Some); + } + // 已知上一代在重试窗口外再次使用,原子撤销整个会话 + current = self + .store + .compare_exchange_session(credential.id, &session, None) + .await + .map_err(|error| map_store_error(error, "authentication session revocation"))?; + } else { + return Ok(None); + } + } + Err(AdminError::new( + AdminErrorKind::Unavailable, + "会话正在变更,请重试", + )) + } +} diff --git a/backend/crates/gateway-admin/tests/backup/support.rs b/backend/crates/gateway-admin/tests/backup/support.rs index eb4ea23b3..64103243c 100644 --- a/backend/crates/gateway-admin/tests/backup/support.rs +++ b/backend/crates/gateway-admin/tests/backup/support.rs @@ -725,11 +725,11 @@ impl AuthStore for FakeAuthStore { ) -> AdminStoreResult<()> { Ok(()) } - async fn renew_session( + async fn compare_exchange_session( &self, _: &str, _: &gateway_admin::model::auth::AuthSession, - _: chrono::DateTime, + _: Option<&gateway_admin::model::auth::AuthSession>, ) -> AdminStoreResult> { Ok(None) } diff --git a/backend/crates/gateway-admin/tests/use_case/auth.rs b/backend/crates/gateway-admin/tests/use_case/auth/mod.rs similarity index 76% rename from backend/crates/gateway-admin/tests/use_case/auth.rs rename to backend/crates/gateway-admin/tests/use_case/auth/mod.rs index 7921dfc84..f28e2a3c6 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth/mod.rs @@ -1,4 +1,4 @@ -//! 验证管理员认证、密码变更、会话绑定与限流行为 +//! 验证管理员认证、密码变更与限流,提供会话轮换的内存存储辅助 use std::{collections::BTreeMap, sync::Mutex}; @@ -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(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() + TimeDelta::hours(1), }, @@ -138,19 +139,24 @@ impl AuthStore for MemoryAuthStore { Ok(()) } - async fn renew_session( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSession, - expires_at: chrono::DateTime, + replacement: Option<&AuthSession>, ) -> AdminStoreResult> { let mut sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.get_mut(session_id) { - if session.expires_at <= chrono::Utc::now() { + if session.retained_until() <= chrono::Utc::now() { return Ok(None); } if session == expected { - session.expires_at = expires_at; + if let Some(replacement) = replacement { + *session = replacement.clone(); + } else { + sessions.remove(session_id); + return Ok(None); + } } return Ok(Some(session.clone())); } @@ -190,8 +196,13 @@ impl AuthStore for MemoryAuthStore { #[tokio::test] async fn successful_login_should_create_expiring_session_and_audit() { let store = std::sync::Arc::new(MemoryAuthStore::default()); - let services = super::AdminHarness::new().auth(store.clone()).build().await; + let services = super::AdminHarness::new() + .session_ttl_minutes(7 * 24 * 60) + .auth(store.clone()) + .build() + .await; + let before = Utc::now(); let result = services .auth() .login( @@ -205,6 +216,17 @@ async fn successful_login_should_create_expiring_session_and_audit() { .await .expect("login"); + let after = Utc::now(); + for (expiry, ttl) in [ + (result.session.expires_at, TimeDelta::hours(24)), + (result.session.retained_until(), TimeDelta::days(7)), + ( + result.session.absolute_expires_at.unwrap(), + TimeDelta::days(30), + ), + ] { + assert!((before + ttl..=after + ttl).contains(&expiry)); + } assert!( services .auth() @@ -293,6 +315,7 @@ async fn expired_sessions_are_rejected_and_store_outages_are_not_treated_as_logo credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() - TimeDelta::seconds(1), }, @@ -362,7 +385,7 @@ async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_ret .await .unwrap(); assert!(!format!("{original:?}").contains(&original.session_id)); - *store.reject_delete.lock().unwrap() = Some(original.session_id.clone()); + *store.reject_delete.lock().unwrap() = Some(stored_id(&original.session_id).to_owned()); assert_eq!( services .auth() @@ -391,109 +414,23 @@ async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_ret 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) - ); +fn stored_id(token: &str) -> &str { + token.split('.').nth(1).unwrap_or(token) } -#[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()); +async fn admin_login( + auth: &dyn gateway_admin::AuthService, +) -> gateway_admin::model::auth::SessionGrant { + auth.login( + LoginCommand::Admin { + username: None, + password: "strong-test-password".into(), + }, + std::net::Ipv4Addr::LOCALHOST.into(), + None, + ) + .await + .unwrap() } + +mod session; diff --git a/backend/crates/gateway-admin/tests/use_case/auth/session.rs b/backend/crates/gateway-admin/tests/use_case/auth/session.rs new file mode 100644 index 000000000..e4a59bdc9 --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/auth/session.rs @@ -0,0 +1,268 @@ +//! 验证双凭据续期、响应重试、并发撤销与固定期限边界 + +use super::super::AdminHarness; +use super::{MemoryAuthStore, admin_login, stored_id}; +use chrono::{TimeDelta, Utc}; +use gateway_admin::model::auth::AuthSession; + +#[tokio::test] +async fn expired_access_is_restored_by_refresh_and_duplicate_refresh_replays_the_same_grant() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + let login = admin_login(auth).await; + let refresh = login.refresh_token.as_deref().unwrap(); + assert!(auth.session(Some(refresh)).await.unwrap().is_none()); + assert!( + auth.renew_session(Some(&login.session_id), None) + .await + .unwrap() + .is_none() + ); + assert!( + auth.renew_session(None, Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + assert_eq!( + auth.renew_session(Some(refresh), None).await.unwrap(), + Some(login.clone()) + ); + store + .sessions + .lock() + .unwrap() + .get_mut(stored_id(&login.session_id)) + .unwrap() + .expires_at = Utc::now() - TimeDelta::seconds(1); + assert!( + auth.session(Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + let (first, concurrent) = tokio::join!( + auth.renew_session(Some(refresh), None), + auth.renew_session(Some(refresh), None) + ); + let first = first.unwrap().unwrap(); + assert_eq!(concurrent.unwrap().as_ref(), Some(&first)); + assert_ne!(first.session_id, login.session_id); + assert_ne!(first.refresh_token, login.refresh_token); + assert_eq!( + first.session.absolute_expires_at, + login.session.absolute_expires_at + ); + assert!( + auth.session(Some(&first.session_id)) + .await + .unwrap() + .is_some() + ); + assert_eq!( + auth.renew_session(first.refresh_token.as_deref(), None) + .await + .unwrap(), + Some(first.clone()) + ); + assert_eq!( + auth.renew_session(Some(refresh), None).await.unwrap(), + Some(first.clone()) + ); + let persisted = format!("{:?}", store.sessions.lock().unwrap()); + for token in [ + &first.session_id, + first.refresh_token.as_ref().unwrap(), + &login.session_id, + login.refresh_token.as_ref().unwrap(), + ] { + assert!(!persisted.contains(token)); + assert!(!persisted.contains(token.rsplit('.').next().unwrap())); + } +} + +#[tokio::test] +async fn late_refresh_reuse_revokes_the_family_but_unknown_secrets_do_not() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + let login = admin_login(auth).await; + store + .sessions + .lock() + .unwrap() + .get_mut(stored_id(&login.session_id)) + .unwrap() + .expires_at = Utc::now() - TimeDelta::seconds(1); + let next = auth + .renew_session(login.refresh_token.as_deref(), None) + .await + .unwrap() + .unwrap(); + let unknown = format!("r2.{}.{}", stored_id(&login.session_id), "A".repeat(43)); + assert!( + auth.renew_session(Some(&unknown), None) + .await + .unwrap() + .is_none() + ); + auth.logout(&unknown).await.unwrap(); + auth.logout(stored_id(&login.session_id)).await.unwrap(); + assert!( + auth.session(Some(&next.session_id)) + .await + .unwrap() + .is_some() + ); + store + .sessions + .lock() + .unwrap() + .get_mut(stored_id(&login.session_id)) + .unwrap() + .renewal + .as_mut() + .unwrap() + .previous + .as_mut() + .unwrap() + .expires_at = Utc::now() - TimeDelta::seconds(1); + assert!( + auth.renew_session(login.refresh_token.as_deref(), None) + .await + .unwrap() + .is_none() + ); + assert!( + auth.session(Some(&next.session_id)) + .await + .unwrap() + .is_none() + ); + assert!( + auth.renew_session(next.refresh_token.as_deref(), None) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn refresh_respects_idle_and_absolute_expiry_and_logout_after_access_expiry() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + for absolute in [false, true] { + let login = admin_login(auth).await; + { + let mut sessions = store.sessions.lock().unwrap(); + let session = sessions.get_mut(stored_id(&login.session_id)).unwrap(); + if absolute { + session.absolute_expires_at = Some(Utc::now() - TimeDelta::seconds(1)); + } else { + session.renewal.as_mut().unwrap().expires_at = Utc::now() - TimeDelta::seconds(1); + } + } + assert!( + auth.renew_session(login.refresh_token.as_deref(), None) + .await + .unwrap() + .is_none() + ); + assert!( + auth.session(Some(&login.session_id)) + .await + .unwrap() + .is_none() + ); + } + let login = admin_login(auth).await; + store + .sessions + .lock() + .unwrap() + .get_mut(stored_id(&login.session_id)) + .unwrap() + .expires_at = Utc::now() - TimeDelta::seconds(1); + auth.logout(login.refresh_token.as_deref().unwrap()) + .await + .unwrap(); + assert!( + auth.renew_session(login.refresh_token.as_deref(), None) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn refreshed_expiry_is_capped_and_store_outages_preserve_the_session() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + let login = admin_login(auth).await; + let limit = Utc::now() + TimeDelta::minutes(1); + { + let mut sessions = store.sessions.lock().unwrap(); + let session = sessions.get_mut(stored_id(&login.session_id)).unwrap(); + session.absolute_expires_at = Some(limit); + session.expires_at = Utc::now() - TimeDelta::seconds(1); + } + store + .unavailable + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!( + auth.renew_session(login.refresh_token.as_deref(), None) + .await + .is_err() + ); + store + .unavailable + .store(false, std::sync::atomic::Ordering::SeqCst); + let refreshed = auth + .renew_session(login.refresh_token.as_deref(), None) + .await + .unwrap() + .unwrap(); + assert_eq!(refreshed.session.expires_at, limit); + assert_eq!(refreshed.session.retained_until(), limit); + assert_eq!(refreshed.session.absolute_expires_at, Some(limit)); +} + +#[tokio::test] +async fn fixed_sessions_cannot_acquire_refresh_credentials() { + let store = std::sync::Arc::new(MemoryAuthStore::default()); + let services = AdminHarness::new().auth(store.clone()).build().await; + let auth = services.auth(); + let login = admin_login(auth).await; + let fixed = AuthSession { + renewal: None, + ..login.session + }; + store + .sessions + .lock() + .unwrap() + .insert("fixed".into(), fixed.clone()); + let grant = auth + .renew_session(None, Some("fixed")) + .await + .unwrap() + .unwrap(); + assert_eq!(grant.session, fixed); + assert!(grant.refresh_token.is_none()); + store + .sessions + .lock() + .unwrap() + .get_mut("fixed") + .unwrap() + .expires_at = Utc::now() - TimeDelta::seconds(1); + assert!( + auth.renew_session(None, Some("fixed")) + .await + .unwrap() + .is_none() + ); +} 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 15f615c35..fe733668b 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth_key.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth_key.rs @@ -285,19 +285,24 @@ impl AuthStore for MemoryKeyAuthStore { Ok(()) } - async fn renew_session( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSession, - expires_at: chrono::DateTime, + replacement: Option<&AuthSession>, ) -> AdminStoreResult> { let mut sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.get_mut(session_id) { - if session.expires_at <= chrono::Utc::now() { + if session.retained_until() <= chrono::Utc::now() { return Ok(None); } if session == expected { - session.expires_at = expires_at; + if let Some(replacement) = replacement { + *session = replacement.clone(); + } else { + sessions.remove(session_id); + return Ok(None); + } } return Ok(Some(session.clone())); } diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 3a6801ec6..f7b3a4b1f 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -369,11 +369,11 @@ impl AuthStore for BootstrapAuthStore { Err(unavailable("admin session")) } - async fn renew_session( + async fn compare_exchange_session( &self, _: &str, _: &gateway_admin::model::auth::AuthSession, - _: chrono::DateTime, + _: Option<&gateway_admin::model::auth::AuthSession>, ) -> AdminStoreResult> { Ok(None) } diff --git a/backend/crates/gateway-api/src/auth.rs b/backend/crates/gateway-api/src/auth.rs index 098d7752e..b3c30d265 100644 --- a/backend/crates/gateway-api/src/auth.rs +++ b/backend/crates/gateway-api/src/auth.rs @@ -138,7 +138,9 @@ where .login( payload.into(), peer.ip(), - session_cookie::value(&headers).as_deref(), + session_cookie::refresh_value(&headers) + .or_else(|| session_cookie::value(&headers)) + .as_deref(), ) .await .map_err(map_login_error)?; @@ -149,8 +151,7 @@ where ) .into_response(), &headers, - &result.session_id, - &result.session, + &result, ) } @@ -177,16 +178,18 @@ async fn refresh_session( where S: SessionState + Send + Sync, { - let session_id = session_cookie::value(&headers); - let session = state + let grant = state .admin_services() .auth() - .renew_session(session_id.as_deref()) + .renew_session( + session_cookie::refresh_value(&headers).as_deref(), + session_cookie::value(&headers).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); + let response = session_response(grant.as_ref().map(|grant| &grant.session)); + if let Some(grant) = grant { + return set_session_cookie(response, &headers, &grant); } // 失效响应不清 Cookie,避免晚到的请求覆盖另一个标签页刚建立的会话 Ok(response) @@ -206,32 +209,72 @@ fn session_response(session: Option<&AuthSession>) -> Response { fn set_session_cookie( mut response: Response, headers: &HeaderMap, - session_id: &str, - session: &AuthSession, + grant: &gateway_admin::model::auth::SessionGrant, ) -> 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}", + append_cookie( + &mut response, + headers, session_cookie::NAME, - session_cookie::attributes(headers) - ); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, - ); + &grant.session_id, + Some(grant.session.expires_at), + )?; + append_cookie( + &mut response, + headers, + session_cookie::REFRESH_NAME, + grant.refresh_token.as_deref().unwrap_or_default(), + grant + .session + .renewal + .as_ref() + .map(|renewal| renewal.expires_at), + )?; Ok(response) } +fn append_cookie( + response: &mut Response, + headers: &HeaderMap, + name: &str, + value: &str, + expires_at: Option>, +) -> Result<(), AdminError> { + let attributes = session_cookie::attributes(headers); + let path = if name == session_cookie::REFRESH_NAME { + "/api/auth" + } else { + "/" + }; + let lifetime = expires_at.map_or_else( + || "Max-Age=0".to_owned(), + |expiry| { + format!( + "Max-Age={}; Expires={}", + expiry + .signed_duration_since(chrono::Utc::now()) + .num_seconds() + .max(1), + expiry.format("%a, %d %b %Y %H:%M:%S GMT") + ) + }, + ); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&format!( + "{name}={value}; Path={path}; {attributes}; {lifetime}" + )) + .map_err(|_| AdminError::internal())?, + ); + Ok(()) +} + async fn logout(State(state): State, headers: HeaderMap) -> Result where S: SessionState + Send + Sync, { - if let Some(session_id) = session_cookie::value(&headers) { + if let Some(session_id) = + session_cookie::refresh_value(&headers).or_else(|| session_cookie::value(&headers)) + { state .admin_services() .auth() @@ -285,15 +328,9 @@ fn clear_session_cookie( mut response: Response, headers: &HeaderMap, ) -> Result { - let cookie = format!( - "{}=; {}; Max-Age=0", - session_cookie::NAME, - session_cookie::attributes(headers) - ); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?, - ); + for name in [session_cookie::NAME, session_cookie::REFRESH_NAME] { + append_cookie(&mut response, headers, name, "", None)?; + } Ok(response) } diff --git a/backend/crates/gateway-api/src/session_cookie.rs b/backend/crates/gateway-api/src/session_cookie.rs index 8a4a56a92..4d418d724 100644 --- a/backend/crates/gateway-api/src/session_cookie.rs +++ b/backend/crates/gateway-api/src/session_cookie.rs @@ -4,6 +4,7 @@ use axum::http::{HeaderMap, header::ORIGIN}; use url::Url; pub(crate) const NAME: &str = "cpr_session"; +pub(crate) const REFRESH_NAME: &str = "cpr_refresh"; pub(crate) fn attributes(headers: &HeaderMap) -> &'static str { // HTTPS 反代回源 HTTP 不改变浏览器 Origin @@ -19,16 +20,24 @@ pub(crate) fn attributes(headers: &HeaderMap) -> &'static str { }) }); if http_origin { - "Path=/; HttpOnly; SameSite=Lax" + "HttpOnly; SameSite=Lax" } else { - "Path=/; Secure; HttpOnly; SameSite=Lax" + "Secure; HttpOnly; SameSite=Lax" } } pub(crate) fn value(headers: &HeaderMap) -> Option { + named_value(headers, NAME) +} + +pub(crate) fn refresh_value(headers: &HeaderMap) -> Option { + named_value(headers, REFRESH_NAME) +} + +fn named_value(headers: &HeaderMap, name: &str) -> Option { let cookie = headers.get("cookie")?.to_str().ok()?; cookie.split(';').find_map(|part| { let (candidate, value) = part.trim().split_once('=')?; - (candidate == NAME).then(|| value.to_owned()) + (candidate == name && !value.is_empty()).then(|| value.to_owned()) }) } diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 5236f14ca..989423fa0 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -321,6 +321,7 @@ impl MemoryAuthStore { }, admin_user_id: "admin_1".to_owned(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() + Duration::hours(1), }, @@ -339,7 +340,7 @@ impl MemoryAuthStore { self.sessions .lock() .unwrap() - .get_mut(session_id) + .get_mut(session_id.split('.').nth(1).unwrap_or(session_id)) .unwrap() .expires_at = expires_at; } @@ -415,19 +416,24 @@ impl AuthStore for MemoryAuthStore { Ok(()) } - async fn renew_session( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSession, - expires_at: chrono::DateTime, + replacement: Option<&AuthSession>, ) -> AdminStoreResult> { let mut sessions = self.sessions.lock().unwrap(); if let Some(session) = sessions.get_mut(session_id) { - if session.expires_at <= chrono::Utc::now() { + if session.retained_until() <= chrono::Utc::now() { return Ok(None); } if session == expected { - session.expires_at = expires_at; + if let Some(replacement) = replacement { + *session = replacement.clone(); + } else { + sessions.remove(session_id); + return Ok(None); + } } return Ok(Some(session.clone())); } diff --git a/backend/crates/gateway-api/tests/auth.rs b/backend/crates/gateway-api/tests/auth.rs index 90e015aef..315209d7f 100644 --- a/backend/crates/gateway-api/tests/auth.rs +++ b/backend/crates/gateway-api/tests/auth.rs @@ -23,6 +23,27 @@ fn session_cookie(response: &axum::response::Response) -> String { .to_owned() } +fn refresh_cookie(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with("cpr_refresh=")) + .expect("refresh cookie") + .split(';') + .next() + .unwrap() + .to_owned() +} + +async fn refresh(app: &axum::Router, cookie: &str) -> axum::response::Response { + app.clone() + .oneshot(cookie_request(Method::POST, "/api/auth/refresh", cookie)) + .await + .unwrap() +} + async fn login( app: &axum::Router, body: Value, @@ -537,64 +558,129 @@ async fn key_auth_should_issue_restore_and_clear_a_unified_cookie() { } #[tokio::test] -async fn refresh_renews_admin_cookie_and_preserves_fixed_key_expiry() { +async fn refresh_restores_expired_access_with_only_the_refresh_cookie() { let (app, fixture) = auth_app().await; - for body in [ + let response = login( + &app, 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)) + None, + ) + .await; + let access = session_cookie(&response); + let renewal = refresh_cookie(&response); + let cookies: Vec<_> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + assert_eq!(cookies.len(), 2); + assert!(cookies[0].contains("Path=/;")); + assert!(cookies[1].contains("Path=/api/auth;")); + assert!( + cookies + .iter() + .all(|value| value.contains("HttpOnly") && value.contains("SameSite=Lax")) + ); + assert_eq!( + response_json(refresh(&app, &access).await).await["data"]["authenticated"], + false + ); + fixture.set_session_expiry( + access.split_once('=').unwrap().1, + chrono::Utc::now() - chrono::Duration::seconds(1), + ); + assert_eq!( + get(&app, "/api/admin/system/version", &access) .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) - ); - } - } + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + response_json(get(&app, "/api/auth/status", &renewal).await).await["data"]["authenticated"], + false + ); + + let response = refresh(&app, &renewal).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let next_access = session_cookie(&response); + let next_renewal = refresh_cookie(&response); + assert_ne!(next_access, access); + assert_ne!(next_renewal, renewal); + assert_eq!(response_json(response).await["data"]["authenticated"], true); + assert_eq!( + get(&app, "/api/admin/system/version", &next_access) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + get(&app, "/api/admin/system/version", &access) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + // 模拟响应丢失后的重试,返回同一代 Cookie + let retry = refresh(&app, &renewal).await; + assert_eq!(session_cookie(&retry), next_access); + assert_eq!(refresh_cookie(&retry), next_renewal); +} + +#[tokio::test] +async fn key_refresh_preserves_fixed_expiry_and_clears_admin_refresh_cookie() { + let (app, _) = auth_app().await; + let admin = login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None, + ) + .await; + let renewal = refresh_cookie(&admin); + let response = login( + &app, + json!({"mode": "key", "apiKey": RAW_KEY}), + Some(&renewal), + ) + .await; + assert_eq!(refresh_cookie(&response), "cpr_refresh="); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .any(|value| { + let value = value.to_str().unwrap(); + value.starts_with("cpr_refresh=") + && value.contains("Max-Age=0") + && value.contains("Path=/api/auth;") + }) + ); + let cookie = session_cookie(&response); + let original = response_json(response).await["data"]["expiresAt"].clone(); + let response = refresh(&app, &cookie).await; + assert_eq!(session_cookie(&response), cookie); + let data = response_json(response).await; + assert_eq!(data["data"]["authenticated"], true); + assert_eq!(data["data"]["session"]["expiresAt"], original); + assert_eq!( + response_json(refresh(&app, &renewal).await).await["data"]["authenticated"], + false + ); } #[tokio::test] async fn refresh_after_logout_or_password_change_cannot_recreate_a_session() { - let (app, _) = auth_app().await; + let (app, fixture) = 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 login_response = login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None, + ) + .await; + let cookie = session_cookie(&login_response); + let renewal = refresh_cookie(&login_response); let response = if password_change { change_password( &app, @@ -604,15 +690,34 @@ async fn refresh_after_logout_or_password_change_cannot_recreate_a_session() { ) .await } else { + fixture.set_session_expiry( + cookie.split_once('=').unwrap().1, + chrono::Utc::now() - chrono::Duration::seconds(1), + ); app.clone() - .oneshot(cookie_request(Method::POST, "/api/auth/logout", &cookie)) + .oneshot(cookie_request(Method::POST, "/api/auth/logout", &renewal)) .await .unwrap() }; assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2 + ); + assert!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .all(|value| value.to_str().unwrap().contains("Max-Age=0")) + ); let response = app .clone() - .oneshot(cookie_request(Method::POST, "/api/auth/refresh", &cookie)) + .oneshot(cookie_request(Method::POST, "/api/auth/refresh", &renewal)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 35c1c3dbd..1a79cfbd8 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -286,17 +286,17 @@ impl AuthStore for AuthStoreAdapter { .map_err(|error| admin_store_error("authentication session", error)) } - async fn renew_session( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSession, - expires_at: chrono::DateTime, + replacement: Option<&AuthSession>, ) -> AdminStoreResult> { - redis::AuthStateRepository::renew_session( + redis::AuthStateRepository::compare_exchange_session( &self.state, session_id, &auth_session_record(expected), - expires_at, + replacement.map(auth_session_record).as_ref(), ) .await .map_err(|error| admin_store_error("authentication session renewal", error))? @@ -399,6 +399,7 @@ fn auth_session(record: redis::AuthSessionRecord) -> AdminStoreResult redis::AuthSessionRecord { subject, expires_at: session.expires_at, absolute_expires_at: session.absolute_expires_at, + renewal: session.renewal.clone(), } } diff --git a/backend/crates/gateway-store/src/redis/auth.rs b/backend/crates/gateway-store/src/redis/auth.rs index d4dff2c57..2c408ca15 100644 --- a/backend/crates/gateway-store/src/redis/auth.rs +++ b/backend/crates/gateway-store/src/redis/auth.rs @@ -55,13 +55,28 @@ pub struct AuthSessionRecord { pub subject: SessionSubjectRecord, pub expires_at: DateTime, pub absolute_expires_at: Option>, + pub renewal: Option, } impl AuthSessionRecord { fn validate(&self) -> StoreResult { self.subject.validate()?; - let expires_at_millis = u64::try_from(self.expires_at.timestamp_millis()) - .map_err(|_| auth_invalid("session expiry must be after the Unix epoch"))?; + if let Some(renewal) = &self.renewal + && (!matches!(self.subject, SessionSubjectRecord::Admin { .. }) + || self.expires_at > renewal.expires_at + || self + .absolute_expires_at + .is_none_or(|limit| renewal.expires_at > limit)) + { + return Err(auth_invalid("invalid renewable session lifetime")); + } + let expires_at_millis = u64::try_from( + self.renewal + .as_ref() + .map_or(self.expires_at, |renewal| renewal.expires_at) + .timestamp_millis(), + ) + .map_err(|_| auth_invalid("session expiry must be after the Unix epoch"))?; if expires_at_millis > MAX_REDIS_EXACT_INTEGER { return Err(auth_invalid( "session expiry is outside the supported range", @@ -82,11 +97,11 @@ 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( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSessionRecord, - expires_at: DateTime, + replacement: Option<&AuthSessionRecord>, ) -> StoreResult>; async fn consume_login_attempt( &self, @@ -127,29 +142,26 @@ impl RedisAuthStateRepository { #[async_trait] impl AuthStateRepository for RedisAuthStateRepository { - async fn renew_session( + async fn compare_exchange_session( &self, session_id: &str, expected: &AuthSessionRecord, - expires_at: DateTime, + replacement: Option<&AuthSessionRecord>, ) -> 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()?; + let payload = replacement.map(encode_session).transpose()?; + let expiry = replacement + .map(AuthSessionRecord::validate) + .transpose()? + .unwrap_or(0); // 比较和写入在同一脚本完成;GET 会检查 Redis 到期,退出后的键不能复活 let payload: Option = Script::new( r#" local current = redis.call('GET', KEYS[1]) if current == ARGV[1] then + if ARGV[2] == '' then + redis.call('DEL', KEYS[1]) + return nil + end redis.call('SET', KEYS[1], ARGV[2], 'PXAT', ARGV[3], 'XX') return ARGV[2] end @@ -158,7 +170,7 @@ return current ) .key(self.session_key(session_id)?) .arg(encode_session(expected)?) - .arg(encode_session(&renewed)?) + .arg(payload.unwrap_or_default()) .arg(expiry) .invoke_async(&mut self.connection.clone()) .await @@ -250,11 +262,14 @@ struct AuthSessionWire { expires_at: String, #[serde(default, skip_serializing_if = "Option::is_none")] absolute_expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + renewal: Option, } fn encode_session(session: &AuthSessionRecord) -> StoreResult { serde_json::to_string(&AuthSessionWire { subject: session.subject.clone(), + renewal: session.renewal.clone(), expires_at: session .expires_at .to_rfc3339_opts(SecondsFormat::Nanos, true), @@ -277,6 +292,7 @@ fn decode_session(value: &str) -> StoreResult { let expires_at = parse(&wire.expires_at)?; Ok(AuthSessionRecord { subject: wire.subject, + renewal: wire.renewal, 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 03e07bd29..966228bd6 100644 --- a/backend/crates/gateway-store/tests/redis/auth.rs +++ b/backend/crates/gateway-store/tests/redis/auth.rs @@ -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(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }; @@ -116,6 +117,7 @@ async fn client_session_should_reject_invalid_intervals_and_rate_limit_policies( subject: SessionSubjectRecord::Key { client_key_id: "key-42".to_owned(), }, + renewal: None, absolute_expires_at: None, expires_at: now, }; @@ -156,6 +158,7 @@ fn admin_auth_state_rejects_invalid_ttl_boundaries() { credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() - chrono::Duration::seconds(1), }; @@ -183,6 +186,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(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }; @@ -252,6 +256,7 @@ async fn unified_session_rejects_invalid_identities_and_loads_legacy_admin_paylo subject: SessionSubjectRecord::Key { client_key_id: "key-42".to_owned(), }, + renewal: None, absolute_expires_at: None, expires_at: Utc::now() + chrono::Duration::seconds(60), }, @@ -309,83 +314,83 @@ 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 { +fn renewable_record() -> AuthSessionRecord { + AuthSessionRecord { subject: SessionSubjectRecord::Admin { admin_user_id: "admin".into(), credential_fingerprint: "fingerprint".into(), }, - expires_at: Utc::now() + chrono::Duration::minutes(1), + expires_at: Utc::now() - chrono::Duration::minutes(1), absolute_expires_at: Some(Utc::now() + chrono::Duration::minutes(10)), + renewal: Some(gateway_admin::model::auth::SessionRenewal { + expires_at: Utc::now() + chrono::Duration::minutes(5), + access_digest: [1; 32], + refresh_digest: [2; 32], + previous: None, + }), + } +} + +#[tokio::test] +async fn rotation_is_atomic_and_logout_cannot_be_undone_by_inflight_refresh() { + let Some((repository, _, _)) = auth_repository().await else { + return; }; + let original = renewable_record(); repository - .store_session("renewal", &original) + .store_session("rotation", &original) .await .unwrap(); - let expiry = Utc::now() + chrono::Duration::minutes(5); + let mut replacement = original.clone(); + replacement.expires_at = Utc::now() + chrono::Duration::minutes(1); + replacement.renewal.as_mut().unwrap().refresh_digest = [3; 32]; + let mut competing = replacement.clone(); + competing.renewal.as_mut().unwrap().refresh_digest = [4; 32]; let (first, second) = tokio::join!( - repository.renew_session("renewal", &original, expiry), - repository.renew_session("renewal", &original, expiry + chrono::Duration::minutes(1)), + repository.compare_exchange_session("rotation", &original, Some(&replacement)), + repository.compare_exchange_session("rotation", &original, Some(&competing)), ); 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!(second.unwrap(), Some(first.clone())); + assert_eq!( + repository.load_session("rotation").await.unwrap(), + Some(first.clone()) + ); + // 过时快照不能撤销已轮换的状态;领域层重新核对后才决定是否撤销 assert_eq!( repository - .renew_session("renewal", &original, original.expires_at) + .compare_exchange_session("rotation", &original, None) .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)) + .compare_exchange_session("rotation", &first, None) .await .unwrap() .is_none() ); - assert!(repository.load_session("renewal").await.unwrap().is_none()); + assert!( + repository + .compare_exchange_session("rotation", &first, Some(&replacement)) + .await + .unwrap() + .is_none() + ); + assert!(repository.load_session("rotation").await.unwrap().is_none()); } #[tokio::test] -async fn renewal_updates_redis_ttl_and_rejects_a_key_that_expired_after_loading() { +async fn redis_retains_refresh_after_access_expiry_but_never_resurrects_expired_sessions() { 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)), - }; + let original = renewable_record(); 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) @@ -397,6 +402,25 @@ async fn renewal_updates_redis_ttl_and_rejects_a_key_that_expired_after_loading( .await .unwrap(); assert!((240_000..=300_000).contains(&ttl)); + assert_eq!( + repository.load_session("expiry-race").await.unwrap(), + Some(original.clone()) + ); + let mut replacement = original.clone(); + replacement.renewal.as_mut().unwrap().expires_at = Utc::now() + chrono::Duration::minutes(6); + assert_eq!( + repository + .compare_exchange_session("expiry-race", &original, Some(&replacement)) + .await + .unwrap(), + Some(replacement.clone()) + ); + let ttl: i64 = redis::cmd("PTTL") + .arg(&keys[0]) + .query_async(&mut connection) + .await + .unwrap(); + assert!((300_000..=360_000).contains(&ttl)); redis::cmd("PEXPIRE") .arg(&keys[0]) .arg(1) @@ -406,11 +430,7 @@ async fn renewal_updates_redis_ttl_and_rejects_a_key_that_expired_after_loading( tokio::time::sleep(Duration::from_millis(20)).await; assert!( repository - .renew_session( - "expiry-race", - &renewed, - expiry + chrono::Duration::minutes(1) - ) + .compare_exchange_session("expiry-race", &replacement, Some(&replacement)) .await .unwrap() .is_none() diff --git a/deploy/README.md b/deploy/README.md index b2e4f483f..ab1ab8bff 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -151,11 +151,14 @@ openssl rand -hex 24 | 会话配置 | 含义 | 配置模板 / 默认值 | | --- | --- | --- | -| `admin.session_ttl_minutes` | 管理员不活动期限 | 模板为 10080 分钟(7 天) | +| `admin.session_ttl_minutes` | 管理员续期凭据有效期,成功续期后重新计时 | 模板为 10080 分钟(7 天) | | `admin.session_absolute_ttl_minutes` | 从登录起计算的最长有效期 | 模板及省略时均为 43200 分钟(30 天) | | `client.session_ttl_minutes` | 密钥身份固定有效期,不随活动续期 | 默认 1440 分钟(1 天) | -修改配置需重启,不能延长已建立会话的最长有效期。浏览器会话与续期规则见[认证 API](../docs/api.md#统一登录与会话),独立于 `/v1/*` 鉴权和限额 +管理员访问凭据最多有效 24 小时,由内部策略控制;不活动期限较短时,访问期限缩短到其四分之一,为自动续期留出窗口。 +推荐使用模板中的 7 天不活动期限和 30 天最长登录期限。修改配置需重启,不能延长已建立会话的最长有效期。 +升级后仅有单凭据的已有会话保留到原期限,重新登录后获得自动续期能力。 +浏览器会话与续期规则见[认证 API](../docs/api.md#统一登录与会话),独立于 `/v1/*` 鉴权和限额 PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区 引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL diff --git a/docs/api.md b/docs/api.md index 0b40245e5..96e1d9f15 100644 --- a/docs/api.md +++ b/docs/api.md @@ -570,15 +570,16 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; ### 统一登录与会话 管理员和密钥登录共用 `/api/auth/*`。登录模式 `mode` 只用于选择凭据验证方式,不直接授予权限; -验证成功后,由后端写入身份和绑定 ID。一个浏览器只持有一份 `cpr_session` HttpOnly Cookie, -原始 Key 不进入 URL、Pinia 或浏览器存储。登录页的切换只改变本地表单,不改变 URL。 +验证成功后,由后端写入身份和绑定 ID。浏览器通过 `cpr_session` HttpOnly Cookie 访问业务接口, +管理员另持有仅发送到 `/api/auth` 的 `cpr_refresh` HttpOnly Cookie。原始 Key 不进入 URL、Pinia 或浏览器存储。 +登录页的切换只改变本地表单,不改变 URL。 管理员进入管理端;Key 登录后进入 `/key-usage`,只读取当前会话绑定 Key 的数据 | 方法 | 路由 | 请求 | 说明 | | --- | --- | --- | --- | | `POST` | `/api/auth/login` | `{ mode: "admin", username?, password }` 或 `{ mode: "key", apiKey }` | 验证凭据、创建会话;成功后撤销请求携带的旧会话 | | `GET` | `/api/auth/status` | 无 | 只读校验 Cookie 对应身份,返回 `{ authenticated, session }` | -| `POST` | `/api/auth/refresh` | 无 | 校验身份并按管理员策略续期,返回同 status 的结构;有效会话同步设置 Cookie | +| `POST` | `/api/auth/refresh` | 无 | 使用管理员续期 Cookie 恢复访问凭据;返回同 status 的结构并同步 Cookie,Key 会话保持原期限 | | `POST` | `/api/auth/logout` | 无 | 删除当前会话并清除 Cookie;存储失败返回 503,不假装退出成功 | | `POST` | `/api/auth/password` | `{ currentPassword, newPassword }` | 仅管理员会话可用;验证当前密码后修改密码,撤销全部管理员会话并清除当前 Cookie | @@ -591,11 +592,14 @@ call 与账号的绑定保存在网关进程内,未连接时一小时过期; 并发修改中只有旧密码哈希仍匹配的请求可以提交,冲突返回 409;密码更新与安全审计在同一事务提交。 该入口共用登录尝试限流,超限返回 429。普通设置变更和管理员 API Key 变更不撤销密码登录会话,密钥身份会话也不受改密影响 -会话由服务端保存,Cookie 属性为 `Path=/; HttpOnly; SameSite=Lax`,`Max-Age` / -`Expires` 对齐服务端有效期,`Secure` 沿用上述 Origin 规则。普通业务请求和状态轮询不会续期。 -管理员通过 refresh 在不活动期限内续期,不能超过登录时确定的最长有效期;已过期或撤销的会话不会重新创建。 -refresh 未认证时返回 `{ authenticated: false, session: null }`,不写 Cookie;依赖故障返回 503。 -密钥身份和缺少最长有效期记录的会话保持固定期限。期限配置见[部署配置](../deploy/README.md#手动安装) +会话由服务端保存,两种 Cookie 均为 `HttpOnly; SameSite=Lax`,`Max-Age` / `Expires` 对齐各自有效期, +`Secure` 沿用上述 Origin 规则。访问 Cookie 使用 `Path=/`,续期 Cookie 使用 `Path=/api/auth`,不能直接访问业务接口。 +普通业务请求和状态轮询不会续期。管理员访问凭据到期后,仍可通过续期 Cookie 恢复登录;续期成功会轮换两种凭据, +并重新计算不活动期限,但不能超过登录时确定的最长有效期。访问凭据仍有效时,refresh 返回同一组凭据和期限。 +轮换后 30 秒内重试上一代续期凭据返回同一组新凭据;超出窗口再次使用已识别的上一代凭据会撤销该会话。 +续期已过期或会话已撤销时不会重新创建;未认证返回 `{ authenticated: false, session: null }`,不写 Cookie;依赖故障返回 503。 +退出可以只携带续期 Cookie,并清除两种 Cookie。Key 登录清除续期 Cookie;密钥身份和仅有单凭据的会话保持固定期限。 +期限配置及升级注意事项见[部署配置](../deploy/README.md#手动安装) 每次恢复密钥会话时重新确认 Key 存在且启用;停用或删除后会话失效,重新启用不会恢复已撤销会话。 依赖不可用时返回 503,不返回已认证或假装未登录。预算耗尽不妨碍登录 @@ -608,7 +612,8 @@ refresh 未认证时返回 `{ authenticated: false, session: null }`,不写 Co 拒绝时使用 `42901` 和 `Retry-After` 认证错误共用 `40101`(会话失效)、`40102`(凭据错误)和 `40301`(权限不足)。 -前端在 `40101` 后统一尝试一次会话恢复,并发请求等待同一恢复结果;恢复成功后最多重放一次,确认失效才退出。 +前端首次读取状态未认证时尝试续期;业务请求在 `40101` 后统一尝试一次会话恢复,并发请求等待同一恢复结果; +恢复成功后最多重放一次,确认失效才退出。 `40102` 和 `40301` 不触发恢复,网络或依赖故障不清除身份 ### Key 用量与客户端配置 diff --git a/docs/architecture.md b/docs/architecture.md index f147da476..4c53909b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -587,9 +587,11 @@ Vue 普通管理请求的错误提示由 `api/request/` 请求封装统一负责 AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;Key 会话不能访问管理员页面和管理接口。 前端只维护一份 Auth Store,不在每个 API 请求上标记身份;401 会话失效、403 权限不足和 503 依赖故障分别处理。 成功登录替换当前浏览器的旧会话,登出必须确认服务端撤销。 -管理员的不活动期限和最长有效期由 AuthService 持有;显式续期验证当前身份并限制写入频率,Store 原子比较会话后延长期限, -不重新创建已退出或过期的键,API 将返回的有效期同步到 Cookie。Key 与缺少最长有效期记录的会话保持固定期限。 -前端首次进入受保护页面只读取登录状态,点击、滚动、聚焦等页面活动不触发续期; +管理员访问凭据和续期凭据绑定同一逻辑会话,AuthService 负责签发、身份校验、轮换、不活动期限和最长有效期。 +Store 保存凭据摘要与上一代重试所需的派生盐,原子比较后替换记录或撤销;Redis TTL 对齐续期期限, +访问到期不删除逻辑会话,刷新不能重建已退出或过期的键。短窗口重试返回同一代凭据,避免响应丢失与并发刷新互相撤销。 +API 只把签发结果投影为两种 HttpOnly Cookie;Key 与单凭据会话保持固定期限。 +前端首次进入受保护页面先读取状态,未认证时尝试续期;点击、滚动、聚焦等页面活动不单独触发续期; 认证 Cookie 写入在支持 Web Locks 的环境中跨标签页串行,其他环境在当前页面串行。 请求层仅在业务接口返回 `40101` 时共用一次会话续期,失败请求及恢复期间的新请求等待同一结果; 成功后原请求最多重放一次,取消或身份已切换的请求不重放,确认失效才退出。恢复接口跳过该协调以避免循环等待。 diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index 91cb93024..d3898485f 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -19,7 +19,10 @@ export const useAuthStore = defineStore('auth', () => { function checkAuth(): Promise { if (pendingCheck.value) return pendingCheck.value - const check = loadSession(getAuthStatus).finally(() => { + const check = loadSession(async () => { + const status = await getAuthStatus() + return status.authenticated ? status : refreshAuthSession() + }).finally(() => { if (pendingCheck.value === check) pendingCheck.value = undefined }) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f19765e18..3aecb96fe 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -21,6 +21,7 @@ export default defineConfig(({ mode }) => { '/dev': { target: 'http://127.0.0.1:8080', changeOrigin: true, + cookiePathRewrite: { '/api/auth': '/dev/api/auth' }, rewrite: path => path.replace(/^\/dev/, ''), }, },