From 16ae8c7afa63c3a92008dd00a7a1ec44a09463d3 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:07:17 +0800 Subject: [PATCH] feat(auth): add administrator password changes (#166) Co-authored-by: Zyy Co-authored-by: Codex --- backend/crates/gateway-admin/Cargo.toml | 2 +- backend/crates/gateway-admin/src/lib.rs | 4 +- .../crates/gateway-admin/src/model/auth.rs | 21 ++- .../crates/gateway-admin/src/ports/store.rs | 9 + .../crates/gateway-admin/src/use_case/auth.rs | 129 +++++++++++++- .../gateway-admin/tests/backup/support.rs | 10 ++ .../gateway-admin/tests/use_case/auth.rs | 72 ++++++++ .../gateway-admin/tests/use_case/auth_key.rs | 10 ++ .../gateway-admin/tests/use_case/mod.rs | 19 ++ backend/crates/gateway-api/src/auth.rs | 59 ++++++- .../crates/gateway-api/tests/admin/auth.rs | 15 ++ backend/crates/gateway-api/tests/admin/mod.rs | 36 ++++ backend/crates/gateway-api/tests/auth.rs | 167 ++++++++++++++++++ .../crates/gateway-store/src/admin_adapter.rs | 112 +++++++----- .../src/postgres/admin_security_audit.rs | 45 +++++ .../crates/gateway-store/src/redis/auth.rs | 13 +- .../tests/postgres/admin_security_audit.rs | 63 +++++++ .../crates/gateway-store/tests/redis/auth.rs | 30 +++- deploy/README.md | 3 + docs/api.md | 6 + docs/architecture.md | 3 + frontend/src/api/modules/auth.ts | 8 + .../settings/components/AdminPasswordCard.vue | 26 +++ .../components/ChangePasswordModal.vue | 79 +++++++++ frontend/src/views/settings/index.vue | 2 + 25 files changed, 882 insertions(+), 61 deletions(-) create mode 100644 frontend/src/views/settings/components/AdminPasswordCard.vue create mode 100644 frontend/src/views/settings/components/ChangePasswordModal.vue diff --git a/backend/crates/gateway-admin/Cargo.toml b/backend/crates/gateway-admin/Cargo.toml index 0ea8235fe..9345fea2a 100644 --- a/backend/crates/gateway-admin/Cargo.toml +++ b/backend/crates/gateway-admin/Cargo.toml @@ -25,6 +25,7 @@ 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"] } secrecy = { version = "0.10.3", features = ["serde"] } +sha2 = "0.11" subtle = "2.6.1" thiserror = "2.0.18" tracing = "0.1.44" @@ -34,7 +35,6 @@ chrono-tz = "0.10" tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] } [dev-dependencies] -sha2 = "0.11" tempfile = "3" tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 3134fb07b..15838c9f8 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -51,7 +51,7 @@ use use_case::{ const OPENAI_PROVIDER_KIND: &str = "openai"; const XAI_PROVIDER_KIND: &str = "xai"; const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12; -const WEAK_INITIAL_PASSWORDS: &[&str] = &[ +const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "", "admin", "123456", @@ -149,7 +149,7 @@ impl AdminConfig { let password = self.default_password.expose().trim(); if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES || password.contains('$') - || WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str()) + || WEAK_ADMIN_PASSWORDS.contains(&password.to_ascii_lowercase().as_str()) { return Err(AdminConfigError::WeakInitialPassword); } diff --git a/backend/crates/gateway-admin/src/model/auth.rs b/backend/crates/gateway-admin/src/model/auth.rs index b077dd506..b99129144 100644 --- a/backend/crates/gateway-admin/src/model/auth.rs +++ b/backend/crates/gateway-admin/src/model/auth.rs @@ -109,8 +109,25 @@ impl From for LoginError { /// 服务端已验证的身份绑定,不保存密码或原始 API Key。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionSubject { - Admin { admin_user_id: String }, - Key { client_key_id: ClientApiKeyId }, + Admin { + admin_user_id: String, + credential_fingerprint: String, + }, + Key { + client_key_id: ClientApiKeyId, + }, +} + +/// 仅已登录管理员可以修改自己的密码。 +pub struct ChangePassword { + pub current_password: String, + pub new_password: String, +} + +impl std::fmt::Debug for ChangePassword { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ChangePassword([REDACTED])") + } } /// 两种登录方式共用的固定有效期会话。 diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 74d477356..f923aef35 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -227,6 +227,15 @@ pub trait AccountRuntimeStore: Send + Sync { pub trait AuthStore: Send + Sync { async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult>; + /// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。 + async fn change_password( + &self, + admin_user_id: &str, + expected_hash: &str, + password_hash: &str, + audit: AdminAuditEvent, + ) -> AdminStoreResult; + async fn create_password_hash_if_absent( &self, admin_user_id: &str, diff --git a/backend/crates/gateway-admin/src/use_case/auth.rs b/backend/crates/gateway-admin/src/use_case/auth.rs index 08050a657..a57462204 100644 --- a/backend/crates/gateway-admin/src/use_case/auth.rs +++ b/backend/crates/gateway-admin/src/use_case/auth.rs @@ -8,6 +8,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{Duration, Utc}; use gateway_core::engine::execution::ClientKeyVerifier; use rand_core::{OsRng, RngCore as _}; +use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq as _; use uuid::Uuid; @@ -15,8 +16,8 @@ use crate::{ model::{ AdminError, AdminErrorKind, auth::{ - AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult, - SessionSubject, + AdminAuditEvent, AuditActorKind, AuthSession, ChangePassword, LoginCommand, LoginError, + LoginResult, SessionSubject, }, }, ports::store::AuthStore, @@ -27,6 +28,12 @@ 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 resolve_admin_user_id( @@ -98,17 +105,21 @@ impl DefaultAuthService { { return Err(LoginError::InvalidCredentials); } - let hash = self + let password_hash = self .store .load_password_hash(&self.default_admin_user_id) .await .map_err(|_| LoginError::Unavailable)? .ok_or(LoginError::InvalidCredentials)?; - if !verify_admin_password(&password, &hash).map_err(|_| LoginError::Unavailable)? { + if password.len() > 4096 + || !verify_admin_password(&password, &password_hash) + .map_err(|_| LoginError::Unavailable)? + { return Err(LoginError::InvalidCredentials); } Ok(SessionSubject::Admin { admin_user_id: self.default_admin_user_id.clone(), + credential_fingerprint: password_fingerprint(&password_hash), }) } LoginCommand::Key { api_key } => { @@ -132,6 +143,75 @@ impl DefaultAuthService { #[async_trait] impl AuthService for DefaultAuthService { + async fn change_password( + &self, + session_id: Option<&str>, + command: ChangePassword, + source_ip: IpAddr, + ) -> Result<(), AdminError> { + let session = self + .session(session_id) + .await? + .ok_or_else(|| AdminError::new(AdminErrorKind::Unauthorized, "请先登录"))?; + let SessionSubject::Admin { + admin_user_id, + credential_fingerprint, + } = session.subject + else { + return Err(AdminError::new( + AdminErrorKind::Forbidden, + "仅管理员可以修改密码", + )); + }; + if self + .store + .consume_login_attempt( + source_ip, + LOGIN_ATTEMPTS_PER_SOURCE, + LOGIN_ATTEMPTS_GLOBAL, + LOGIN_WINDOW, + ) + .await + .map_err(|error| map_store_error(error, "password change limit"))? + .is_some() + { + return Err(AdminError::new( + AdminErrorKind::RateLimited, + "尝试过于频繁,请稍后再试", + )); + } + validate_new_password(&command.new_password)?; + let password_hash = self + .store + .load_password_hash(&admin_user_id) + .await + .map_err(|error| map_store_error(error, "administrator"))? + .filter(|hash| password_fingerprint(hash) == credential_fingerprint) + .ok_or_else(|| AdminError::conflict("密码已变更,请重新登录"))?; + if command.current_password.len() > 4096 + || !verify_admin_password(&command.current_password, &password_hash)? + { + return Err(AdminError::invalid("当前密码不正确")); + } + if command.new_password == command.current_password { + return Err(AdminError::invalid("新密码不能与当前密码相同")); + } + let hash = hash_admin_password(&command.new_password)?; + let mut audit = self.auth_audit("admin.password_changed", &admin_user_id); + audit.entity_kind = "admin_user".to_owned(); + audit.changed_fields = vec!["password".to_owned()]; + if !self + .store + .change_password(&admin_user_id, &password_hash, &hash, audit) + .await + .map_err(|error| map_store_error(error, "administrator password"))? + { + return Err(AdminError::conflict("密码已变更,请重新登录")); + } + // 密码事务提交后旧指纹不再匹配,会话撤销不依赖 Redis 删除成功。 + Ok(()) + } + async fn ensure_default_admin(&self, password: &str) -> Result { let hash = hash_admin_password(password)?; self.store @@ -156,6 +236,23 @@ impl AuthService for DefaultAuthService { let _ = self.store.delete_session(session_id).await; 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; + return Ok(None); + } + } if let SessionSubject::Key { client_key_id } = &session.subject && !self .store @@ -178,7 +275,7 @@ impl AuthService for DefaultAuthService { .await? .map(|session| session.subject) { - Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)), + Some(SessionSubject::Admin { admin_user_id, .. }) => Ok(Some(admin_user_id)), Some(SessionSubject::Key { .. }) => Err(AdminError::new( AdminErrorKind::Forbidden, "当前身份无权访问管理接口", @@ -237,7 +334,7 @@ impl AuthService for DefaultAuthService { .store_session(&session_id, &session) .await .map_err(|_| LoginError::Unavailable)?; - if let SessionSubject::Admin { admin_user_id } = &session.subject + if let SessionSubject::Admin { admin_user_id, .. } = &session.subject && self .store .append_audit_event(self.auth_audit("admin.login", admin_user_id)) @@ -267,7 +364,7 @@ impl AuthService for DefaultAuthService { .await .map_err(|error| map_store_error(error, "authentication session"))?; if let Some(AuthSession { - subject: SessionSubject::Admin { admin_user_id }, + subject: SessionSubject::Admin { admin_user_id, .. }, .. }) = session { @@ -288,6 +385,19 @@ fn session_ttl(minutes: u64) -> Duration { ) } +fn validate_new_password(password: &str) -> Result<(), AdminError> { + if password.trim().chars().count() < 12 + || password.len() > 1024 + || password.chars().any(char::is_control) + || crate::WEAK_ADMIN_PASSWORDS.contains(&password.trim().to_ascii_lowercase().as_str()) + { + return Err(AdminError::invalid( + "新密码至少需要 12 个字符,最多 1024 字节,不能使用常见弱口令或控制字符", + )); + } + Ok(()) +} + fn hash_admin_password(password: &str) -> Result { Argon2::default() .hash_password(password.as_bytes()) @@ -314,3 +424,8 @@ 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/tests/backup/support.rs b/backend/crates/gateway-admin/tests/backup/support.rs index 91c62d3df..1a8d62ef5 100644 --- a/backend/crates/gateway-admin/tests/backup/support.rs +++ b/backend/crates/gateway-admin/tests/backup/support.rs @@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore { async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult> { Ok(None) } + async fn change_password( + &self, + _: &str, + _: &str, + _: &str, + _: gateway_admin::model::auth::AdminAuditEvent, + ) -> AdminStoreResult { + Ok(false) + } + async fn create_password_hash_if_absent( &self, _admin_user_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 3ba0055ca..8cae34cc1 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth.rs @@ -21,12 +21,83 @@ struct MemoryAuthStore { audits: Mutex>, } +#[tokio::test] +async fn password_change_obeys_rate_limit_and_rejects_unbound_legacy_sessions() { + use gateway_admin::model::{ + AdminErrorKind, + auth::{ChangePassword, SessionSubject}, + }; + 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(); + *store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(30)); + let error = auth + .change_password( + Some(&login.session_id), + ChangePassword { + current_password: "strong-test-password".into(), + new_password: "new-strong-password".into(), + }, + std::net::Ipv4Addr::LOCALHOST.into(), + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::RateLimited); + assert!( + auth.session(Some(&login.session_id)) + .await + .unwrap() + .is_some() + ); + store.sessions.lock().unwrap().insert( + "legacy".into(), + AuthSession { + subject: SessionSubject::Admin { + admin_user_id: "admin".into(), + credential_fingerprint: String::new(), + }, + expires_at: Utc::now() + TimeDelta::hours(1), + }, + ); + assert!(auth.session(Some("legacy")).await.unwrap().is_none()); +} + #[async_trait] impl AuthStore for MemoryAuthStore { async fn load_password_hash(&self, _: &str) -> AdminStoreResult> { Ok(self.password_hash.lock().expect("password hash").clone()) } + async fn change_password( + &self, + _: &str, + expected_hash: &str, + password_hash: &str, + audit: gateway_admin::model::auth::AdminAuditEvent, + ) -> AdminStoreResult { + let mut stored = self.password_hash.lock().unwrap(); + let Some(credentials) = stored + .as_mut() + .filter(|value| value.as_str() == expected_hash) + else { + return Ok(false); + }; + *credentials = password_hash.to_owned(); + self.audits.lock().unwrap().push(audit); + Ok(true) + } + async fn create_password_hash_if_absent( &self, _: &str, @@ -197,6 +268,7 @@ async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logou "expired".to_owned(), AuthSession { subject: SessionSubject::Admin { + credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, expires_at: Utc::now() - TimeDelta::seconds(1), 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 c4a0033c7..406356160 100644 --- a/backend/crates/gateway-admin/tests/use_case/auth_key.rs +++ b/backend/crates/gateway-admin/tests/use_case/auth_key.rs @@ -242,6 +242,16 @@ impl AuthStore for MemoryKeyAuthStore { async fn load_password_hash(&self, _: &str) -> AdminStoreResult> { Ok(None) } + async fn change_password( + &self, + _: &str, + _: &str, + _: &str, + _: gateway_admin::model::auth::AdminAuditEvent, + ) -> AdminStoreResult { + Ok(false) + } + async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult { Ok(true) } diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index e90bbaa09..b330844a4 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -283,6 +283,25 @@ impl AuthStore for BootstrapAuthStore { Ok(self.password_hash.lock().expect("password hash").clone()) } + async fn change_password( + &self, + _: &str, + expected_hash: &str, + password_hash: &str, + audit: gateway_admin::model::auth::AdminAuditEvent, + ) -> AdminStoreResult { + let mut stored = self.password_hash.lock().unwrap(); + let Some(credentials) = stored + .as_mut() + .filter(|value| value.as_str() == expected_hash) + else { + return Ok(false); + }; + *credentials = password_hash.to_owned(); + let _ = audit; + Ok(true) + } + async fn create_password_hash_if_absent( &self, _: &str, diff --git a/backend/crates/gateway-api/src/auth.rs b/backend/crates/gateway-api/src/auth.rs index 41094dd41..4e652cb88 100644 --- a/backend/crates/gateway-api/src/auth.rs +++ b/backend/crates/gateway-api/src/auth.rs @@ -12,7 +12,7 @@ use axum::{ }; use gateway_admin::{ AdminServices, - model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject}, + model::auth::{AuthSession, ChangePassword, LoginCommand, LoginError, SessionSubject}, }; use serde::{Deserialize, Serialize}; @@ -39,6 +39,19 @@ pub enum LoginRequest { }, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChangePasswordRequest { + current_password: String, + new_password: String, +} + +impl fmt::Debug for ChangePasswordRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ChangePasswordRequest([REDACTED])") + } +} + impl fmt::Debug for LoginRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -102,6 +115,7 @@ where .route("/api/auth/login", post(login::)) .route("/api/auth/status", get(session_status::)) .route("/api/auth/logout", post(logout::)) + .route("/api/auth/password", post(change_password::)) .route("/api/auth", any(not_found)) .route("/api/auth/{*path}", any(not_found)) .method_not_allowed_fallback(method_not_allowed) @@ -189,17 +203,56 @@ where .await .map_err(map_admin_service_error)?; } - let mut response = AdminResponse::new( + let response = AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(LogoutData { message: "Logged out successfully", }), ) .into_response(); + clear_session_cookie(response, &headers) +} + +async fn change_password( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + AdminJson(payload): AdminJson, +) -> Result +where + S: SessionState + Send + Sync, +{ + state + .admin_services() + .auth() + .change_password( + session_cookie::value(&headers).as_deref(), + ChangePassword { + current_password: payload.current_password, + new_password: payload.new_password, + }, + peer.ip(), + ) + .await + .map_err(map_admin_service_error)?; + let response = AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(LogoutData { + message: "密码已修改,请重新登录", + }), + ) + .into_response(); + clear_session_cookie(response, &headers) +} + +fn clear_session_cookie( + mut response: Response, + headers: &HeaderMap, +) -> Result { let cookie = format!( "{}=; {}; Max-Age=0", session_cookie::NAME, - session_cookie::attributes(&headers) + session_cookie::attributes(headers) ); response.headers_mut().insert( header::SET_COOKIE, diff --git a/backend/crates/gateway-api/tests/admin/auth.rs b/backend/crates/gateway-api/tests/admin/auth.rs index e184fa51a..7039f6611 100644 --- a/backend/crates/gateway-api/tests/admin/auth.rs +++ b/backend/crates/gateway-api/tests/admin/auth.rs @@ -4,6 +4,21 @@ use serde_json::json; use super::AdminTestFixture; +#[test] +fn password_change_request_rejects_unknown_fields_and_redacts_both_passwords() { + let request: gateway_api::auth::ChangePasswordRequest = serde_json::from_value(json!({ + "currentPassword": "current-password-secret", "newPassword": "new-password-secret" + })) + .unwrap(); + assert!(!format!("{request:?}").contains("password-secret")); + assert!( + serde_json::from_value::(json!({ + "currentPassword": "old", "newPassword": "new", "username": "another-admin" + })) + .is_err() + ); +} + #[test] fn login_request_should_deny_unknown_fields_and_redact_password_debug() { let password = "admin-password-must-not-leak"; diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 81ebd1b2a..6ae3102f0 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -271,6 +271,20 @@ impl MemoryAuthStore { session_id.to_owned(), AuthSession { subject: gateway_admin::model::auth::SessionSubject::Admin { + credential_fingerprint: { + use base64::Engine as _; + use sha2::Digest as _; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode( + sha2::Sha256::digest( + self.password_hash + .lock() + .unwrap() + .as_ref() + .unwrap() + .as_bytes(), + ), + ) + }, admin_user_id: "admin_1".to_owned(), }, expires_at: Utc::now() + Duration::hours(1), @@ -301,6 +315,28 @@ impl AuthStore for MemoryAuthStore { Ok(self.password_hash.lock().expect("password hash").clone()) } + async fn change_password( + &self, + _: &str, + expected_hash: &str, + password_hash: &str, + audit: gateway_admin::model::auth::AdminAuditEvent, + ) -> AdminStoreResult { + if self.fail_audit.load(Ordering::SeqCst) { + return Err(unavailable("audit")); + } + let mut stored = self.password_hash.lock().unwrap(); + let Some(credentials) = stored + .as_mut() + .filter(|value| value.as_str() == expected_hash) + else { + return Ok(false); + }; + *credentials = password_hash.to_owned(); + self.audits.lock().unwrap().push(audit); + Ok(true) + } + async fn create_password_hash_if_absent( &self, _: &str, diff --git a/backend/crates/gateway-api/tests/auth.rs b/backend/crates/gateway-api/tests/auth.rs index 12e41b685..69a0c48dd 100644 --- a/backend/crates/gateway-api/tests/auth.rs +++ b/backend/crates/gateway-api/tests/auth.rs @@ -42,6 +42,173 @@ async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Re .expect("GET response") } +async fn change_password( + app: &axum::Router, + cookie: Option<&str>, + current: &str, + new: &str, +) -> axum::response::Response { + let mut request = json_request( + Method::POST, + "/api/auth/password", + json!({"currentPassword": current, "newPassword": new}), + ); + if let Some(cookie) = cookie { + request + .headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + } + app.clone().oneshot(request).await.unwrap() +} + +#[tokio::test] +async fn password_change_revokes_all_admin_sessions_but_preserves_key_sessions() { + let (app, fixture) = auth_app().await; + let admin_body = json!({"mode": "admin", "password": "strong-admin-password"}); + let first = session_cookie(&login(&app, admin_body.clone(), None).await); + let second = session_cookie(&login(&app, admin_body.clone(), None).await); + let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await); + fixture.set_api_key(&format!("admin-{}", "b".repeat(64))); + assert_eq!( + get(&app, "/api/admin/system/version", &first) + .await + .status(), + StatusCode::OK + ); + let response = change_password( + &app, + Some(&first), + "strong-admin-password", + "new-strong-admin-password", + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .contains("Max-Age=0") + ); + for cookie in [&first, &second] { + assert_eq!( + get(&app, "/api/admin/system/version", cookie) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + } + assert_eq!( + response_json(get(&app, "/api/auth/status", &key).await).await["data"]["authenticated"], + true + ); + assert_eq!( + login(&app, admin_body, None).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + login( + &app, + json!({"mode": "admin", "password": "new-strong-admin-password"}), + None + ) + .await + .status(), + StatusCode::OK + ); +} + +#[tokio::test] +async fn password_change_requires_admin_session_and_keeps_session_on_validation_failure() { + let (app, _) = auth_app().await; + assert_eq!( + change_password(&app, None, "strong-admin-password", "new-strong-password") + .await + .status(), + StatusCode::UNAUTHORIZED + ); + let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await); + assert_eq!( + change_password( + &app, + Some(&key), + "strong-admin-password", + "new-strong-password" + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + let admin = session_cookie( + &login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None, + ) + .await, + ); + for (current, new) in [ + ("wrong-current-password", "new-strong-password"), + ("strong-admin-password", "short"), + ("strong-admin-password", "strong-admin-password"), + ] { + assert_eq!( + change_password(&app, Some(&admin), current, new) + .await + .status(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + get(&app, "/api/admin/system/version", &admin) + .await + .status(), + StatusCode::OK + ); + } +} + +#[tokio::test] +async fn password_change_audit_failure_preserves_password_and_session() { + let (app, fixture) = auth_app().await; + let admin = session_cookie( + &login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None, + ) + .await, + ); + fixture.fail_audit(true); + assert_eq!( + change_password( + &app, + Some(&admin), + "strong-admin-password", + "new-strong-password" + ) + .await + .status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + get(&app, "/api/admin/system/version", &admin) + .await + .status(), + StatusCode::OK + ); + fixture.fail_audit(false); + assert_eq!( + login( + &app, + json!({"mode": "admin", "password": "strong-admin-password"}), + None + ) + .await + .status(), + StatusCode::OK + ); +} + #[tokio::test] async fn unified_login_returns_server_identity_and_rotates_the_previous_session() { let (app, _) = auth_app().await; diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index f2043044f..58a24d99c 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -281,6 +281,24 @@ impl AuthStore for AuthStoreAdapter { .map_err(|error| admin_store_error("admin authentication", error)) } + async fn change_password( + &self, + admin_user_id: &str, + expected_hash: &str, + password_hash: &str, + audit: AdminAuditModel, + ) -> AdminStoreResult { + postgres::AdminSecurityAuditRepository::change_password( + &self.security, + admin_user_id, + expected_hash, + password_hash, + auth_audit_record(audit)?, + ) + .await + .map_err(|error| admin_store_error("administrator password", error)) + } + async fn create_password_hash_if_absent( &self, admin_user_id: &str, @@ -312,8 +330,12 @@ 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 } => redis::SessionSubjectRecord::Admin { + 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(), @@ -365,57 +387,63 @@ impl AuthStore for AuthStoreAdapter { } async fn append_audit_event(&self, event: AdminAuditModel) -> AdminStoreResult<()> { - let config_revision = event - .config_revision - .map(|revision| i64::try_from(revision.get())) - .transpose() - .map_err(|_| { - AdminStoreError::new( - AdminStoreErrorKind::Invalid, - "admin audit", - "config revision is outside the supported range", - ) - })?; - let actor_kind = match event.actor_kind { - gateway_admin::model::auth::AuditActorKind::AdminSession => { - postgres::AdminAuditActorKind::AdminSession - } - gateway_admin::model::auth::AuditActorKind::AdminApiKey => { - postgres::AdminAuditActorKind::AdminApiKey - } - gateway_admin::model::auth::AuditActorKind::System => { - postgres::AdminAuditActorKind::System - } - gateway_admin::model::auth::AuditActorKind::Anonymous => { - postgres::AdminAuditActorKind::Anonymous - } - }; postgres::AdminSecurityAuditRepository::append_admin_audit_event( &self.security, - postgres::AdminAuditEvent { - id: event.id, - actor_kind, - actor_admin_user_id: event.actor_admin_user_id, - actor_ref: event.actor_ref, - admin_request_id: event.request_id, - action: event.action, - entity_kind: event.entity_kind, - entity_ref: event.entity_ref, - config_revision, - changed_fields: event.changed_fields, - created_at: event.occurred_at, - }, + auth_audit_record(event)?, ) .await .map_err(|error| admin_store_error("admin audit", error)) } } +fn auth_audit_record(event: AdminAuditModel) -> AdminStoreResult { + let config_revision = event + .config_revision + .map(|revision| i64::try_from(revision.get())) + .transpose() + .map_err(|_| { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + "admin audit", + "config revision is outside the supported range", + ) + })?; + let actor_kind = match event.actor_kind { + gateway_admin::model::auth::AuditActorKind::AdminSession => { + postgres::AdminAuditActorKind::AdminSession + } + gateway_admin::model::auth::AuditActorKind::AdminApiKey => { + postgres::AdminAuditActorKind::AdminApiKey + } + gateway_admin::model::auth::AuditActorKind::System => postgres::AdminAuditActorKind::System, + gateway_admin::model::auth::AuditActorKind::Anonymous => { + postgres::AdminAuditActorKind::Anonymous + } + }; + Ok(postgres::AdminAuditEvent { + id: event.id, + actor_kind, + actor_admin_user_id: event.actor_admin_user_id, + actor_ref: event.actor_ref, + admin_request_id: event.request_id, + action: event.action, + entity_kind: event.entity_kind, + entity_ref: event.entity_ref, + config_revision, + changed_fields: event.changed_fields, + created_at: event.occurred_at, + }) +} + fn auth_session(record: redis::AuthSessionRecord) -> AdminStoreResult { let subject = match record.subject { - redis::SessionSubjectRecord::Admin { admin_user_id } => { - SessionSubject::Admin { admin_user_id } - } + redis::SessionSubjectRecord::Admin { + admin_user_id, + credential_fingerprint, + } => SessionSubject::Admin { + admin_user_id, + credential_fingerprint, + }, redis::SessionSubjectRecord::Key { client_key_id } => SessionSubject::Key { client_key_id: gateway_core::policy::ClientApiKeyId::new(client_key_id).map_err( |_| { diff --git a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs index e41f8a8fa..77094d40c 100644 --- a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs +++ b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs @@ -71,6 +71,14 @@ impl AdminAuditEvent { pub trait AdminSecurityAuditRepository: Send + Sync { async fn password_hash(&self, admin_user_id: &str) -> StoreResult>; + async fn change_password( + &self, + admin_user_id: &str, + expected_hash: &str, + password_hash: &str, + audit: AdminAuditEvent, + ) -> StoreResult; + async fn create_password_hash_if_absent( &self, admin_user_id: &str, @@ -103,6 +111,43 @@ impl AdminSecurityAuditRepository for PgAdminSecurityAuditRepository { .map_err(|_| postgres_unavailable("read admin password hash")) } + async fn change_password( + &self, + admin_user_id: &str, + expected_hash: &str, + password_hash: &str, + audit: AdminAuditEvent, + ) -> StoreResult { + require_nonempty("admin user", "id", admin_user_id)?; + require_nonempty("admin user", "password_hash", password_hash)?; + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| postgres_unavailable("begin password change"))?; + let changed = sqlx::query( + "update admin_users set password_hash = $3, updated_at = now() + where id = $1 and password_hash = $2", + ) + .bind(admin_user_id) + .bind(expected_hash) + .bind(password_hash) + .execute(&mut *transaction) + .await + .map_err(|_| postgres_unavailable("change admin password"))? + .rows_affected() + == 1; + if !changed { + return Ok(false); + } + append_admin_audit_event_in_transaction(&mut transaction, audit, None).await?; + transaction + .commit() + .await + .map_err(|_| postgres_unavailable("commit password change"))?; + Ok(true) + } + async fn create_password_hash_if_absent( &self, admin_user_id: &str, diff --git a/backend/crates/gateway-store/src/redis/auth.rs b/backend/crates/gateway-store/src/redis/auth.rs index cae8c6084..86c9fbada 100644 --- a/backend/crates/gateway-store/src/redis/auth.rs +++ b/backend/crates/gateway-store/src/redis/auth.rs @@ -25,14 +25,21 @@ return 0 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum SessionSubjectRecord { - Admin { admin_user_id: String }, - Key { client_key_id: String }, + Admin { + admin_user_id: String, + // 旧会话没有指纹,按未认证处理并要求重新登录。 + #[serde(default)] + credential_fingerprint: String, + }, + Key { + client_key_id: String, + }, } impl SessionSubjectRecord { fn validate(&self) -> StoreResult<()> { match self { - Self::Admin { admin_user_id } => { + Self::Admin { admin_user_id, .. } => { require_nonempty("authentication session", "admin_user_id", admin_user_id) } Self::Key { client_key_id } => { diff --git a/backend/crates/gateway-store/tests/postgres/admin_security_audit.rs b/backend/crates/gateway-store/tests/postgres/admin_security_audit.rs index b5b872367..ffc9dd098 100644 --- a/backend/crates/gateway-store/tests/postgres/admin_security_audit.rs +++ b/backend/crates/gateway-store/tests/postgres/admin_security_audit.rs @@ -1,6 +1,69 @@ use chrono::Utc; use gateway_store::postgres::{AdminAuditActorKind, AdminAuditEvent}; +#[tokio::test] +async fn password_change_commits_audit_atomically_and_rejects_concurrent_old_hash() { + use gateway_store::postgres::{AdminSecurityAuditRepository, PgAdminSecurityAuditRepository}; + let Some(database) = super::TestDatabase::create("password_change").await else { + return; + }; + let repository = PgAdminSecurityAuditRepository::new(database.pool.clone()); + repository + .create_password_hash_if_absent("admin", "old-test-hash") + .await + .unwrap(); + let audit = |id: &str| AdminAuditEvent { + id: id.into(), + actor_kind: AdminAuditActorKind::AdminSession, + actor_admin_user_id: Some("admin".into()), + actor_ref: "admin:admin".into(), + admin_request_id: None, + action: "admin.password_changed".into(), + entity_kind: "admin_user".into(), + entity_ref: "admin".into(), + config_revision: None, + changed_fields: vec!["password".into()], + created_at: Utc::now(), + }; + let (first, second) = tokio::join!( + repository.change_password( + "admin", + "old-test-hash", + "first-new-hash", + audit("change-1") + ), + repository.change_password( + "admin", + "old-test-hash", + "second-new-hash", + audit("change-2") + ), + ); + assert_ne!(first.unwrap(), second.unwrap()); + let current = repository.password_hash("admin").await.unwrap().unwrap(); + assert!(current == "first-new-hash" || current == "second-new-hash"); + let count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(count, 1); + + // 制造真实审计约束失败,确认密码更新随同事务回滚。 + let mut invalid_audit = audit("invalid-change"); + invalid_audit.actor_admin_user_id = Some("missing-admin".into()); + assert!( + repository + .change_password("admin", ¤t, "must-not-commit", invalid_audit) + .await + .is_err() + ); + assert_eq!( + repository.password_hash("admin").await.unwrap().as_deref(), + Some(current.as_str()) + ); + database.close().await; +} + #[test] fn audit_event_rejects_more_than_sixty_four_changed_fields() { let event = AdminAuditEvent { diff --git a/backend/crates/gateway-store/tests/redis/auth.rs b/backend/crates/gateway-store/tests/redis/auth.rs index bc4e42630..74c068fb7 100644 --- a/backend/crates/gateway-store/tests/redis/auth.rs +++ b/backend/crates/gateway-store/tests/redis/auth.rs @@ -149,6 +149,7 @@ async fn auth_repository() -> Option<(RedisAuthStateRepository, ConnectionManage fn admin_auth_state_rejects_invalid_ttl_boundaries() { let session = AuthSessionRecord { subject: SessionSubjectRecord::Admin { + credential_fingerprint: String::new(), admin_user_id: "admin".to_owned(), }, expires_at: Utc::now() - chrono::Duration::seconds(1), @@ -174,6 +175,7 @@ async fn admin_auth_state_keeps_fixed_ttl_and_opaque_keys() { let admin_api_key = "admin-must-never-enter-redis"; let session = AuthSessionRecord { subject: SessionSubjectRecord::Admin { + credential_fingerprint: "test-password-fingerprint".to_owned(), admin_user_id: "default-admin".to_owned(), }, expires_at: Utc::now() + chrono::Duration::seconds(60), @@ -232,7 +234,7 @@ async fn admin_auth_state_keeps_fixed_ttl_and_opaque_keys() { } #[tokio::test] -async fn unified_session_rejects_unknown_or_mixed_identity_payloads() { +async fn unified_session_rejects_invalid_identities_and_loads_legacy_admin_payloads() { let Some((repository, mut connection, namespace)) = auth_repository().await else { return; }; @@ -272,4 +274,30 @@ async fn unified_session_rejects_unknown_or_mixed_identity_payloads() { .unwrap(); assert!(repository.load_session(token).await.is_err()); } + + // 旧版会话仍能解码,空指纹交由认证服务判定失效,避免升级后返回存储故障。 + let legacy = serde_json::json!({ + "subject": {"type": "admin", "admin_user_id": "admin"}, + "expires_at": (Utc::now() + chrono::Duration::seconds(60)).to_rfc3339(), + }); + redis::cmd("SET") + .arg(&keys[0]) + .arg(legacy.to_string()) + .arg("EX") + .arg(60) + .query_async::<()>(&mut connection) + .await + .unwrap(); + assert_eq!( + repository + .load_session(token) + .await + .unwrap() + .unwrap() + .subject, + SessionSubjectRecord::Admin { + admin_user_id: "admin".to_owned(), + credential_fingerprint: String::new(), + } + ); } diff --git a/deploy/README.md b/deploy/README.md index 1c08c7130..d91ead0ae 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -342,9 +342,12 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr ## 密码语义 - `admin.default_password` 只在首次创建管理员时使用。 +- 已有管理员在「系统设置 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。 - PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。 - Redis 在每次容器创建时使用 `redis.password`。 +管理员会话绑定密码哈希指纹;从尚未包含此字段的版本升级后,管理员需要重新登录一次,密钥身份会话不受影响。 + 已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致 应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis 密码变更后需要用新配置重新创建 Redis 和应用容器,不需要删除 Redis 数据目录。 diff --git a/docs/api.md b/docs/api.md index 2c12f505e..881c12e24 100644 --- a/docs/api.md +++ b/docs/api.md @@ -301,11 +301,17 @@ SSE/WS 的 `response.failed` 保留原消息、响应 ID 与其他业务字段 | `POST` | `/api/auth/login` | `{ mode: "admin", username?, password }` 或 `{ mode: "key", apiKey }` | 验证凭据、创建会话;成功后撤销请求携带的旧会话 | | `GET` | `/api/auth/status` | 无 | 从 Cookie 恢复服务端身份,返回 `{ authenticated, session }` | | `POST` | `/api/auth/logout` | 无 | 删除当前会话并清除 Cookie;存储失败返回 503,不假装退出成功 | +| `POST` | `/api/auth/password` | `{ currentPassword, newPassword }` | 仅管理员会话可用;验证当前密码后修改密码,撤销全部管理员会话并清除当前 Cookie | 登录返回 `data: { role: "admin" | "key", expiresAt }`;status 已登录时的 `session` 使用同一结构, 未登录时为 `{ authenticated: false, session: null }`。`role` 由服务端已验证身份推导,不接受客户端声明。 不返回凭据或绑定 ID。 +修改密码要求新密码至少 12 个字符、最多 1024 字节,不能包含控制字符、使用常见弱口令或与当前密码相同。 +成功返回 `{ message }`,需要重新登录;当前密码错误或新密码不合法返回 400,并保留原会话。 +并发修改中只有旧密码哈希仍匹配的请求可以提交,冲突返回 409;密码更新与安全审计在同一事务提交。 +该入口共用登录尝试限流,超限返回 429。普通设置变更和管理员 API Key 变更不撤销密码登录会话,密钥身份会话也不受改密影响。 + 会话由服务端保存,Cookie 属性为 `Path=/; HttpOnly; SameSite=Lax`,`Max-Age` / `Expires` 对齐固定有效期,`Secure` 沿用上述 Origin 规则。轮询不会续期。 管理员有效期由 `admin.session_ttl_minutes` 控制;密钥有效期由 `client.session_ttl_minutes` 控制,默认 1440 分钟。 diff --git a/docs/architecture.md b/docs/architecture.md index 4600c0437..883e0461e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -331,6 +331,9 @@ API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排 AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;Key 会话不能访问管理员页面和管理接口。 前端只维护一份 Auth Store,不在每个 API 请求上标记身份;401 会话失效、403 权限不足和 503 依赖故障分别处理。 成功登录替换旧会话,登出必须确认服务端撤销。 +管理员会话保存由已加盐密码哈希派生的指纹,每次恢复时与 PostgreSQL 当前密码核对;普通设置变更不影响该绑定。 +改密在 AuthService 验证当前密码和新密码策略,Store 以旧哈希条件更新密码并在同一 PostgreSQL 事务记录审计。 +事务提交后旧管理员会话的指纹失配,不依赖 Redis 批量删除完成撤销;原始密码及密码哈希不进入 Redis。 KeyUsageService 从 AuthService 的服务端身份确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和 ObservabilityStore 的范围查询;API 只输出单页所需的字段白名单,不复用管理员的宽响应。 前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口。 diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts index bb0f7d02a..fe56c87e1 100644 --- a/frontend/src/api/modules/auth.ts +++ b/frontend/src/api/modules/auth.ts @@ -39,3 +39,11 @@ export function 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/views/settings/components/AdminPasswordCard.vue b/frontend/src/views/settings/components/AdminPasswordCard.vue new file mode 100644 index 000000000..163bd3f7e --- /dev/null +++ b/frontend/src/views/settings/components/AdminPasswordCard.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/views/settings/components/ChangePasswordModal.vue b/frontend/src/views/settings/components/ChangePasswordModal.vue new file mode 100644 index 000000000..67ed89d57 --- /dev/null +++ b/frontend/src/views/settings/components/ChangePasswordModal.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index a608d1d43..3c0364550 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -13,6 +13,7 @@ import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor import AccountAutoFreezeCard from './components/AccountAutoFreezeCard.vue' import AdminApiKeyCard from './components/AdminApiKeyCard.vue' +import AdminPasswordCard from './components/AdminPasswordCard.vue' import SettingsBackupSection from './components/backup/SettingsBackupSection.vue' import ClientVersionSettings from './components/client-version/index.vue' import FastPolicyCard from './components/FastPolicyCard.vue' @@ -137,6 +138,7 @@ watch(section, (value) => { 正在加载设置… +