mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ¤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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,9 +342,12 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
|
||||
## 密码语义
|
||||
|
||||
- `admin.default_password` 只在首次创建管理员时使用。
|
||||
- 已有管理员在「系统设置 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。
|
||||
- PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。
|
||||
- Redis 在每次容器创建时使用 `redis.password`。
|
||||
|
||||
管理员会话绑定密码哈希指纹;从尚未包含此字段的版本升级后,管理员需要重新登录一次,密钥身份会话不受影响。
|
||||
|
||||
已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致
|
||||
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis
|
||||
密码变更后需要用新配置重新创建 Redis 和应用容器,不需要删除 Redis 数据目录。
|
||||
|
||||
@@ -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 分钟。
|
||||
|
||||
@@ -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` 独立于管理布局,不挂载管理员菜单或请求管理接口。
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user