feat(auth): add administrator password changes (#166)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-19 06:07:17 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 956f627f68
commit 16ae8c7afa
25 changed files with 882 additions and 61 deletions
+1 -1
View File
@@ -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"] }
+2 -2
View File
@@ -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);
}
+19 -2
View File
@@ -109,8 +109,25 @@ impl From<ClientAuthenticationError> 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])")
}
}
/// 两种登录方式共用的固定有效期会话。
@@ -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<Option<String>>;
/// 密码更新与审计必须在同一事务提交;旧哈希不匹配时不写入。
async fn change_password(
&self,
admin_user_id: &str,
expected_hash: &str,
password_hash: &str,
audit: AdminAuditEvent,
) -> AdminStoreResult<bool>;
async fn create_password_hash_if_absent(
&self,
admin_user_id: &str,
@@ -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<bool, AdminError>;
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, 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<bool, AdminError> {
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<String, AdminError> {
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()))
}
@@ -618,6 +618,16 @@ impl AuthStore for FakeAuthStore {
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(
&self,
_admin_user_id: &str,
@@ -21,12 +21,83 @@ struct MemoryAuthStore {
audits: Mutex<Vec<AdminAuditEvent>>,
}
#[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<Option<String>> {
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<bool> {
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),
@@ -242,6 +242,16 @@ impl AuthStore for MemoryKeyAuthStore {
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
Ok(None)
}
async fn change_password(
&self,
_: &str,
_: &str,
_: &str,
_: gateway_admin::model::auth::AdminAuditEvent,
) -> AdminStoreResult<bool> {
Ok(false)
}
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
Ok(true)
}
@@ -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<bool> {
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,
+56 -3
View File
@@ -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::<S>))
.route("/api/auth/status", get(session_status::<S>))
.route("/api/auth/logout", post(logout::<S>))
.route("/api/auth/password", post(change_password::<S>))
.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<S>(
State(state): State<S>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
AdminJson(payload): AdminJson<ChangePasswordRequest>,
) -> Result<Response, AdminError>
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<Response, AdminError> {
let cookie = format!(
"{}=; {}; Max-Age=0",
session_cookie::NAME,
session_cookie::attributes(&headers)
session_cookie::attributes(headers)
);
response.headers_mut().insert(
header::SET_COOKIE,
@@ -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::<gateway_api::auth::ChangePasswordRequest>(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";
@@ -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<bool> {
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,
+167
View File
@@ -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;
@@ -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<bool> {
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<postgres::AdminAuditEvent> {
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<AuthSession> {
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(
|_| {
@@ -71,6 +71,14 @@ impl AdminAuditEvent {
pub trait AdminSecurityAuditRepository: Send + Sync {
async fn password_hash(&self, admin_user_id: &str) -> StoreResult<Option<String>>;
async fn change_password(
&self,
admin_user_id: &str,
expected_hash: &str,
password_hash: &str,
audit: AdminAuditEvent,
) -> StoreResult<bool>;
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<bool> {
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,
+10 -3
View File
@@ -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 } => {
@@ -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", &current, "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 {
@@ -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(),
}
);
}
+3
View File
@@ -342,9 +342,12 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
## 密码语义
- `admin.default_password` 只在首次创建管理员时使用。
- 已有管理员在「系统设置 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。
- PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。
- Redis 在每次容器创建时使用 `redis.password`。
管理员会话绑定密码哈希指纹;从尚未包含此字段的版本升级后,管理员需要重新登录一次,密钥身份会话不受影响。
已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis
密码变更后需要用新配置重新创建 Redis 和应用容器,不需要删除 Redis 数据目录。
+6
View File
@@ -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 分钟。
+3
View File
@@ -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` 独立于管理布局,不挂载管理员菜单或请求管理接口。
+8
View File
@@ -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,
})
}
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { LockKeyhole } from '@lucide/vue'
import { shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import ChangePasswordModal from './ChangePasswordModal.vue'
const open = shallowRef(false)
</script>
<template>
<BaseCard title="管理员密码" description="用于登录管理后台">
<template #actions>
<BaseButton variant="secondary" @click="open = true">
<template #icon>
<LockKeyhole class="size-4" />
</template>
修改密码
</BaseButton>
</template>
<p class="m-0 text-cp leading-relaxed text-cp-text-secondary">
修改后,所有已登录的管理员都需要使用新密码重新登录。
</p>
</BaseCard>
<ChangePasswordModal v-model="open" />
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, reactive, shallowRef, useId, watch } from 'vue'
import { useRouter } from 'vue-router'
import { changeAdminPassword } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import FormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useAuthStore } from '@/stores/modules/auth'
const open = defineModel<boolean>({ default: false })
const formId = useId()
const router = useRouter()
const auth = useAuthStore()
const { loading, run } = useAsyncAction()
const submitted = shallowRef(false)
const form = reactive({ currentPassword: '', newPassword: '', confirmation: '' })
const passwordError = computed(() => {
if (!submitted.value)
return undefined
if ([...form.newPassword.trim()].length < 12)
return '新密码至少需要 12 个字符'
if (new TextEncoder().encode(form.newPassword).length > 1024)
return '新密码不能超过 1024 字节'
if (form.newPassword === form.currentPassword)
return '新密码不能与当前密码相同'
return undefined
})
const confirmationError = computed(() => submitted.value && form.confirmation !== form.newPassword
? '两次输入的新密码不一致'
: undefined)
watch(open, () => {
Object.assign(form, { currentPassword: '', newPassword: '', confirmation: '' })
submitted.value = false
})
async function submit() {
submitted.value = true
if (!form.currentPassword || passwordError.value || confirmationError.value)
return
await run(async () => {
await changeAdminPassword({ currentPassword: form.currentPassword, newPassword: form.newPassword })
auth.invalidateSession()
open.value = false
toast.success('密码已修改,请使用新密码重新登录')
await router.replace('/login')
})
}
</script>
<template>
<BaseModal v-model="open" title="修改管理员密码" description="验证当前密码后,设置新的登录密码" size="sm" :dismissible="!loading">
<form :id="formId" class="grid gap-5" @submit.prevent="submit">
<FormItem label="当前密码" required>
<BaseInput v-model="form.currentPassword" type="password" autocomplete="current-password" placeholder="输入当前密码" :disabled="loading" maxlength="4096" />
</FormItem>
<FormItem label="新密码" description="至少 12 个字符,建议混合使用字母、数字和符号" :error="passwordError" required>
<BaseInput v-model="form.newPassword" type="password" autocomplete="new-password" placeholder="设置新密码" :disabled="loading" maxlength="1024" />
</FormItem>
<FormItem label="确认新密码" :error="confirmationError" required>
<BaseInput v-model="form.confirmation" type="password" autocomplete="new-password" placeholder="再次输入新密码" :disabled="loading" maxlength="1024" />
</FormItem>
<p class="m-0 rounded-cp bg-cp-fill-quaternary px-3 py-2.5 text-cp-sm leading-relaxed text-cp-text-secondary">
保存后,当前及其他设备上的管理员登录都会失效,需要重新登录。
</p>
</form>
<template #footer>
<BaseButton variant="secondary" :disabled="loading" @click="open = false">
取消
</BaseButton>
<BaseButton type="submit" :form="formId" variant="primary" :loading="loading">
保存并重新登录
</BaseButton>
</template>
</BaseModal>
</template>
+2
View File
@@ -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) => {
正在加载设置…
</div>
<AdminPasswordCard />
<AdminApiKeyCard
:status="adminApiKeyStatus"
:loading="adminKeyLoading"