feat(auth): keep unsaved passwords in session memory
This commit is contained in:
1 parent
bcec60ebef
commit
1ff24aaeb4
23 files changed
+1265
-65
No files matched your search
@@ -196,6 +196,8 @@ pub async fn change_password(
|
||||
pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Response {
|
||||
if let Some(token) = extract_session_token(&req) {
|
||||
state.sessions.write().await.remove(&token);
|
||||
// 登出只清除当前登录会话的临时密码,不影响其他会话与桌面端凭据。
|
||||
state.app.session_credentials.clear_owner(&token);
|
||||
}
|
||||
let cookie = format!("dbx_session=; Path={}; HttpOnly; Max-Age=0", session_cookie_path(&state));
|
||||
(StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response()
|
||||
@@ -243,9 +245,17 @@ pub async fn auth_middleware(
|
||||
}
|
||||
|
||||
// Check session token
|
||||
if let Some(token) = extract_session_token(&req) {
|
||||
if state.sessions.read().await.contains(&token) {
|
||||
return next.run(req).await;
|
||||
let token = extract_session_token(&req);
|
||||
if let Some(ref token) = token {
|
||||
if state.sessions.read().await.contains(token) {
|
||||
// 在下游处理器及其 await 到的池创建路径上注入当前登录会话的 owner 作用域,
|
||||
// 使 save_password=false 连接的临时密码按会话隔离(见 SessionCredentialStore)。
|
||||
let owner = token.clone();
|
||||
return dbx_core::session_credentials::with_credential_owner(
|
||||
Some(owner),
|
||||
async move { next.run(req).await },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,8 @@ async fn main() {
|
||||
.route("/connection/final-proxy-port", post(routes::connection::connection_final_proxy_port))
|
||||
.route("/connection/disconnect", post(routes::connection::disconnect_db))
|
||||
.route("/connection/check-health", post(routes::connection::check_connection_health))
|
||||
.route("/connection/session-credential-status", post(routes::connection::session_credential_status))
|
||||
.route("/connection/forget-session-credential", post(routes::connection::forget_session_credential))
|
||||
.route("/connection/identifier-quote", post(routes::connection::connection_identifier_quote))
|
||||
.route("/connection/close-database", post(routes::connection::close_database_connection))
|
||||
.route("/connection/save", post(routes::connection::save_connections))
|
||||
|
||||
@@ -2,11 +2,13 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use dbx_core::connection::{AppState, PoolKind};
|
||||
use dbx_core::connection::{connection_configs_pool_equivalent, AppState, PoolKind};
|
||||
use dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::session_token_from_headers;
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
@@ -34,6 +36,12 @@ pub struct CloseDatabaseConnectionRequest {
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionCredentialStatusRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionIdentifierQuoteRequest {
|
||||
@@ -253,6 +261,7 @@ pub async fn test_connection_with_info(
|
||||
|
||||
pub async fn connect_db(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ConnectRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let config = body.config;
|
||||
@@ -266,16 +275,44 @@ pub async fn connect_db(
|
||||
}
|
||||
let app = &state.app;
|
||||
let connection_id = config.id.clone();
|
||||
let owner = session_token_from_headers(&headers).unwrap_or_default();
|
||||
let attempt = app.begin_connection_attempt_with_client_attempt(&connection_id, body.client_attempt).await;
|
||||
|
||||
app.remove_connection_pools_detached(&connection_id).await;
|
||||
app.reset_connection_transport_for_config(&connection_id, &config).await;
|
||||
app.configs.write().await.insert(connection_id.clone(), config.clone());
|
||||
// save_password=false 连接:
|
||||
// 1) 先把本次输入的密码按 owner(登录会话)记入内存会话凭据仓库,供池创建/
|
||||
// 池重建按 owner 读取(见 apply_session_credential);
|
||||
// 2) 存入全局运行态 configs 的配置必须脱敏(password 恒为空),禁止明文驻留
|
||||
// AppState.configs——否则其他登录会话借池重建即可读到它,绕过 owner 隔离。
|
||||
// 只有本次请求确实写入/替换了会话凭据(输入了非空密码)时,才需要在连接
|
||||
// 失败时回滚它;无密码重连复用的是既有有效凭据,失败不应清掉它,否则瞬时的
|
||||
// 数据库/网络抖动就会让"本次会话内记住密码"失效、下次又弹窗。
|
||||
let session_credential_write = (!config.save_password)
|
||||
.then(|| app.session_credentials.set(&owner, &connection_id, &config.password))
|
||||
.flatten();
|
||||
let no_save_password = !config.save_password;
|
||||
let mut runtime_config = config.clone();
|
||||
if no_save_password {
|
||||
runtime_config.password.clear();
|
||||
}
|
||||
|
||||
app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await.map_err(AppError::from)?;
|
||||
app.remove_connection_pools_detached(&connection_id).await;
|
||||
app.reset_connection_transport_for_config(&connection_id, &runtime_config).await;
|
||||
app.configs.write().await.insert(connection_id.clone(), runtime_config);
|
||||
|
||||
if let Err(error) = app.get_or_create_pool_for_connection_attempt(&connection_id, None, attempt).await {
|
||||
// 连接失败:仅回滚本次请求刚写入的会话凭据,避免前端误判"已记住密码"而用
|
||||
// 失败密码免弹窗重试;无密码重连复用的既有凭据不受瞬时失败影响。
|
||||
if let Some(token) = session_credential_write.as_ref() {
|
||||
app.session_credentials.remove_if_current(token);
|
||||
}
|
||||
return Err(AppError::from(error));
|
||||
}
|
||||
if let Err(error) = sync_mongo_legacy_driver_fallback(&state, &config).await {
|
||||
app.remove_connection_pools_detached(&connection_id).await;
|
||||
app.reset_connection_transport_for_config(&connection_id, &config).await;
|
||||
if let Some(token) = session_credential_write.as_ref() {
|
||||
app.session_credentials.remove_if_current(token);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
@@ -308,6 +345,7 @@ pub async fn save_connection_database_info(
|
||||
|
||||
pub async fn connection_final_proxy_port(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ConnectRequest>,
|
||||
) -> Result<Json<u16>, AppError> {
|
||||
let runtime_config = body.config.canonicalized();
|
||||
@@ -326,9 +364,19 @@ pub async fn connection_final_proxy_port(
|
||||
let app = &state.app;
|
||||
let connection_id = runtime_config.id.clone();
|
||||
let db_config = dbx_core::connection::metadata_connection_config(&runtime_config);
|
||||
app.configs.write().await.insert(connection_id.clone(), runtime_config);
|
||||
// Tunnel resolution needs a runtime config lookup, but a no-save password
|
||||
// must never enter the shared Web config cache and bypass owner isolation.
|
||||
let mut stored_config = runtime_config.clone();
|
||||
if !stored_config.save_password {
|
||||
stored_config.password.clear();
|
||||
}
|
||||
app.configs.write().await.insert(connection_id.clone(), stored_config);
|
||||
|
||||
let (_, port) = app.connection_host_port(&connection_id, &db_config).await.map_err(AppError::from)?;
|
||||
if !runtime_config.save_password && !runtime_config.password.is_empty() {
|
||||
let owner = session_token_from_headers(&headers).unwrap_or_default();
|
||||
let _ = app.session_credentials.set(&owner, &connection_id, &runtime_config.password);
|
||||
}
|
||||
Ok(Json(port))
|
||||
}
|
||||
|
||||
@@ -368,6 +416,36 @@ pub async fn check_connection_health(
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
/// 查询连接在本次运行期是否已输入并暂存密码(`save_password=false`)。
|
||||
/// 供前端决定是否需要弹密码框;仅返回布尔状态,不泄露密码本身。
|
||||
/// 按当前登录会话(owner)查询,不会暴露其他会话的凭据状态。
|
||||
pub async fn session_credential_status(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SessionCredentialStatusRequest>,
|
||||
) -> Result<Json<bool>, AppError> {
|
||||
let owner = session_token_from_headers(&headers).unwrap_or_default();
|
||||
Ok(Json(state.app.session_credentials.has(&owner, &body.connection_id)))
|
||||
}
|
||||
|
||||
/// "断开并忘记本次密码":清除连接本次运行期的临时密码,下次连接需重新输入。
|
||||
/// 只清当前登录会话的内存凭据,不影响其他会话与持久化配置。
|
||||
pub async fn forget_session_credential(
|
||||
State(state): State<Arc<WebState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SessionCredentialStatusRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let owner = session_token_from_headers(&headers).unwrap_or_default();
|
||||
if !state.app.session_credentials.has(&owner, &body.connection_id) {
|
||||
return Err(AppError::from(format!(
|
||||
"Connection has no transient session credential to forget: {}",
|
||||
body.connection_id
|
||||
)));
|
||||
}
|
||||
state.app.session_credentials.remove(&owner, &body.connection_id);
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn connection_identifier_quote(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<ConnectionIdentifierQuoteRequest>,
|
||||
@@ -391,6 +469,7 @@ pub async fn close_database_connection(
|
||||
|
||||
pub async fn save_connections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
_headers: HeaderMap,
|
||||
Json(body): Json<SaveConnectionsRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
for config in &body.configs {
|
||||
@@ -416,6 +495,8 @@ pub async fn mcp_add_connection(
|
||||
Json(body): Json<McpAddConnectionRequest>,
|
||||
) -> Result<Json<ConnectionConfig>, AppError> {
|
||||
let saved = state.app.storage.add_connection_for_mcp(body.config).await.map_err(AppError::from)?;
|
||||
state.app.session_credentials.clear_connection(&saved.id);
|
||||
state.app.remove_connection_pools_detached(&saved.id).await;
|
||||
state.app.configs.write().await.insert(saved.id.clone(), saved.clone());
|
||||
Ok(Json(saved))
|
||||
}
|
||||
@@ -430,18 +511,22 @@ pub async fn mcp_duplicate_connection(
|
||||
.duplicate_connection_for_mcp(&body.source_id, &body.copy_id, &body.copy_name)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
state.app.session_credentials.clear_connection(&saved.id);
|
||||
state.app.remove_connection_pools_detached(&saved.id).await;
|
||||
state.app.configs.write().await.insert(saved.id.clone(), saved.clone());
|
||||
Ok(Json(saved))
|
||||
}
|
||||
|
||||
pub async fn mcp_remove_connection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
_headers: HeaderMap,
|
||||
Json(body): Json<McpRemoveConnectionRequest>,
|
||||
) -> Result<Json<bool>, AppError> {
|
||||
let connection_id = body.connection_id;
|
||||
let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError::from)?;
|
||||
if removed {
|
||||
state.app.configs.write().await.remove(&connection_id);
|
||||
state.app.session_credentials.clear_connection(&connection_id);
|
||||
state.app.remove_connection_pools_detached(&connection_id).await;
|
||||
state.app.nacos_registry.drop_connection(&connection_id).await;
|
||||
#[cfg(feature = "mq-admin")]
|
||||
@@ -450,7 +535,10 @@ pub async fn mcp_remove_connection(
|
||||
Ok(Json(removed))
|
||||
}
|
||||
|
||||
pub async fn load_connections(State(state): State<Arc<WebState>>) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
|
||||
pub async fn load_connections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
_headers: HeaderMap,
|
||||
) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
|
||||
let configs = state.app.storage.load_connections().await.map_err(AppError::from)?;
|
||||
let sync = sync_connection_configs(&state, &configs).await;
|
||||
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await;
|
||||
@@ -476,6 +564,8 @@ async fn sync_connection_configs(state: &WebState, configs: &[ConnectionConfig])
|
||||
true
|
||||
} else {
|
||||
connection_pool_ids_to_drop.insert(id.clone());
|
||||
// 连接已被全局删除:清除所有 Web owner 的临时凭据与池 owner。
|
||||
state.app.session_credentials.clear_connection(id);
|
||||
if existing.db_type == dbx_core::models::connection::DatabaseType::Nacos {
|
||||
nacos_adapter_ids_to_drop.insert(id.clone());
|
||||
}
|
||||
@@ -499,7 +589,12 @@ async fn sync_connection_configs(state: &WebState, configs: &[ConnectionConfig])
|
||||
if previous.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
|
||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
if &previous != config {
|
||||
// 仅在真实连接参数变化时销毁池;save_password=false 连接因持久化
|
||||
// 空密码与运行态密码产生的差异被忽略(见 connection_configs_pool_equivalent)。
|
||||
if !connection_configs_pool_equivalent(&previous, config) {
|
||||
// 连接端点/认证参数已全局变化:清除所有 owner 的旧凭据与池 owner,
|
||||
// 避免其他登录会话把旧密码或旧池复用到新的连接定义。
|
||||
state.app.session_credentials.clear_connection(&config.id);
|
||||
connection_pool_ids_to_drop.insert(config.id.clone());
|
||||
}
|
||||
}
|
||||
@@ -542,13 +637,14 @@ mod tests {
|
||||
use super::{
|
||||
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections,
|
||||
mark_mongo_legacy_driver, mcp_add_connection, mcp_duplicate_connection, mcp_remove_connection,
|
||||
run_temporary_connection_test, save_connection_database_info, save_connections, test_connection,
|
||||
test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
|
||||
run_temporary_connection_test, save_connection_database_info, save_connections, sync_connection_configs,
|
||||
test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
|
||||
McpDuplicateConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
|
||||
SaveConnectionsRequest,
|
||||
};
|
||||
use crate::state::WebState;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use dbx_core::connection::{AppState, PoolKind};
|
||||
use dbx_core::models::connection::{
|
||||
@@ -838,10 +934,13 @@ mod tests {
|
||||
name: "analytics".to_string(),
|
||||
path: dir.join("analytics.sqlite").to_string_lossy().to_string(),
|
||||
});
|
||||
let connect_error =
|
||||
connect_db(State(state.clone()), Json(ConnectRequest { config: invalid.clone(), client_attempt: None }))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let connect_error = connect_db(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(ConnectRequest { config: invalid.clone(), client_attempt: None }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(connect_error.message.contains("in-memory main database"), "{}", connect_error.message);
|
||||
|
||||
invalid.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
@@ -858,6 +957,7 @@ mod tests {
|
||||
}));
|
||||
let proxy_error = connection_final_proxy_port(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(ConnectRequest { config: invalid, client_attempt: None }),
|
||||
)
|
||||
.await
|
||||
@@ -909,9 +1009,12 @@ mod tests {
|
||||
let db_path = dir.join("app.db");
|
||||
let config = sqlite_config("sqlite-conn", &db_path.to_string_lossy());
|
||||
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![config.clone()] }))
|
||||
.await;
|
||||
let result = save_connections(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(SaveConnectionsRequest { configs: vec![config.clone()] }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
@@ -1010,6 +1113,7 @@ mod tests {
|
||||
|
||||
let removed_result = mcp_remove_connection(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(McpRemoveConnectionRequest { connection_id: removed.id.clone() }),
|
||||
)
|
||||
.await
|
||||
@@ -1019,6 +1123,7 @@ mod tests {
|
||||
|
||||
let scope_error = mcp_remove_connection(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(McpRemoveConnectionRequest { connection_id: kept.id.clone() }),
|
||||
)
|
||||
.await
|
||||
@@ -1086,9 +1191,12 @@ mod tests {
|
||||
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
|
||||
|
||||
let updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![updated.clone()] }))
|
||||
.await;
|
||||
let result = save_connections(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(SaveConnectionsRequest { configs: vec![updated.clone()] }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cached_admin_url = state
|
||||
@@ -1120,9 +1228,12 @@ mod tests {
|
||||
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
|
||||
|
||||
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
|
||||
let result =
|
||||
connect_db(State(state.clone()), Json(ConnectRequest { config: updated.clone(), client_attempt: None }))
|
||||
.await;
|
||||
let result = connect_db(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(ConnectRequest { config: updated.clone(), client_attempt: None }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap().adapter;
|
||||
@@ -1131,6 +1242,134 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn cookie_headers(token: &str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("dbx_session={token}").parse().unwrap());
|
||||
headers
|
||||
}
|
||||
|
||||
/// 回归:Web connect_db 不得把明文 no-save 密码写入全局 AppState.configs,
|
||||
/// 且凭据严格按登录会话隔离——会话 B 无法复用/重建出会话 A 的密码。
|
||||
#[tokio::test]
|
||||
async fn connect_db_sanitizes_global_config_and_scopes_password_by_session() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let db_path = dir.join("a.db").to_string_lossy().to_string();
|
||||
std::fs::File::create(&db_path).unwrap();
|
||||
{
|
||||
let mut sessions = state.sessions.write().await;
|
||||
sessions.insert("token-a".to_string());
|
||||
sessions.insert("token-b".to_string());
|
||||
}
|
||||
let headers_a = cookie_headers("token-a");
|
||||
|
||||
let mut config = sqlite_config("conn-a", &db_path);
|
||||
config.save_password = false;
|
||||
config.password = "hunter2".to_string();
|
||||
|
||||
// 会话 A 以输入的密码连接(在 A 的 owner 作用域内,模拟中间件注入)。
|
||||
let connect_guard = state.clone();
|
||||
let result = dbx_core::session_credentials::with_credential_owner(Some("token-a".to_string()), async move {
|
||||
connect_db(
|
||||
State(connect_guard),
|
||||
headers_a,
|
||||
Json(ConnectRequest { config: config.clone(), client_attempt: None }),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 全局运行态配置不含明文密码(泄露面消除)。
|
||||
let stored = state.app.configs.read().await.get("conn-a").cloned().unwrap();
|
||||
assert_eq!(stored.password, "");
|
||||
// A 的凭据按 owner 记录;B 不可见。
|
||||
assert!(state.app.session_credentials.has("token-a", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has("token-b", "conn-a"));
|
||||
|
||||
// A 自身重建(owner=token-a 作用域)能从会话仓库取回密码。
|
||||
dbx_core::session_credentials::with_credential_owner(Some("token-a".to_string()), async {
|
||||
let mut db_config = dbx_core::connection::metadata_connection_config(&stored);
|
||||
state.app.apply_session_credential(&stored, &mut db_config, "conn-a");
|
||||
assert_eq!(db_config.password, "hunter2");
|
||||
})
|
||||
.await;
|
||||
// 会话 B 重建只能得到空密码——拿不到 A 的密码。
|
||||
dbx_core::session_credentials::with_credential_owner(Some("token-b".to_string()), async {
|
||||
let mut db_config = dbx_core::connection::metadata_connection_config(&stored);
|
||||
state.app.apply_session_credential(&stored, &mut db_config, "conn-a");
|
||||
assert_eq!(db_config.password, "");
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_final_proxy_port_sanitizes_no_save_passwords() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let mut config = sqlite_config("conn-a", "127.0.0.1");
|
||||
config.port = 3306;
|
||||
config.save_password = false;
|
||||
config.password = "session-secret".to_string();
|
||||
config.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||
id: "proxy".to_string(),
|
||||
name: "Proxy".to_string(),
|
||||
enabled: true,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 65000,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
test_target: None,
|
||||
profile_id: String::new(),
|
||||
}));
|
||||
|
||||
let result = connection_final_proxy_port(
|
||||
State(state.clone()),
|
||||
cookie_headers("token-a"),
|
||||
Json(ConnectRequest { config, client_attempt: None }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(state.app.configs.read().await["conn-a"].password, "");
|
||||
assert!(state.app.session_credentials.has("token-a", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has("token-b", "conn-a"));
|
||||
|
||||
state.app.reset_connection_transport("conn-a").await;
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// 回归:无密码重连(复用既有会话凭据)失败时,不得清除既有凭据——瞬时
|
||||
/// 数据库/网络故障不应让"本次会话内记住密码"失效、下次又弹窗。
|
||||
#[tokio::test]
|
||||
async fn failed_passwordless_reconnect_preserves_existing_session_credential() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let headers_a = cookie_headers("token-a");
|
||||
|
||||
let mut config = sqlite_config("conn-a", &dir.join("missing.db").to_string_lossy());
|
||||
config.save_password = false;
|
||||
config.password.clear();
|
||||
|
||||
// 会话 A 已持有该连接的有效会话凭据。
|
||||
let _ = state.app.session_credentials.set("token-a", "conn-a", "hunter2");
|
||||
assert!(state.app.session_credentials.has("token-a", "conn-a"));
|
||||
|
||||
// 无密码重连(配置里没带密码,应复用既有凭据)因目标文件缺失而失败。
|
||||
let result = connect_db(
|
||||
State(state.clone()),
|
||||
headers_a,
|
||||
Json(ConnectRequest { config: config.clone(), client_attempt: None }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// 既有会话凭据必须保留,下次重连无需重新输入密码。
|
||||
assert!(state.app.session_credentials.has("token-a", "conn-a"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_connections_drops_cached_pool_for_updated_config() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
@@ -1140,7 +1379,7 @@ mod tests {
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
let result = load_connections(State(state.clone())).await;
|
||||
let result = load_connections(State(state.clone()), HeaderMap::new()).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
@@ -1156,6 +1395,94 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_connection_configs_ignores_password_only_changes() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let mut initial = sqlite_config("conn-a", &dir.join("a.db").to_string_lossy());
|
||||
initial.save_password = false;
|
||||
initial.password = "session-secret".to_string();
|
||||
let _ = state.app.session_credentials.set("", "conn-a", "session-secret");
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
|
||||
// 持久化同步的空密码 config 覆盖运行态:save_password=false 连接仅密码
|
||||
// 差异不应销毁池(会话密码由内存仓库提供,与运行态 config 无关)。
|
||||
let mut updated = initial.clone();
|
||||
updated.password.clear();
|
||||
let sync = sync_connection_configs(&state, std::slice::from_ref(&updated)).await;
|
||||
assert!(sync.connection_pool_ids_to_drop.is_empty());
|
||||
assert_eq!(state.app.configs.read().await.get("conn-a").map(|c| c.password.as_str()), Some(""));
|
||||
assert!(state.app.session_credentials.has("", "conn-a"));
|
||||
|
||||
// 真实连接参数(host)变化应销毁池,并清除旧会话凭据以便重新输入。
|
||||
let mut host_changed = updated.clone();
|
||||
host_changed.host = "other-host.db".to_string();
|
||||
let sync2 = sync_connection_configs(&state, std::slice::from_ref(&host_changed)).await;
|
||||
assert_eq!(sync2.connection_pool_ids_to_drop.as_slice(), &["conn-a".to_string()]);
|
||||
assert!(!state.app.session_credentials.has("", "conn-a"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_connection_configs_clears_all_owner_credentials_on_global_edit() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let mut initial = sqlite_config("conn-a", &dir.join("a.db").to_string_lossy());
|
||||
initial.save_password = false;
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
let _ = state.app.session_credentials.set("token-a", "conn-a", "owner-a-secret");
|
||||
let _ = state.app.session_credentials.set("token-b", "conn-a", "owner-b-secret");
|
||||
state.app.session_credentials.record_pool_owner("conn-a", "token-a");
|
||||
assert!(state.app.session_credentials.pool_owner_mismatch("conn-a", "token-b"));
|
||||
|
||||
let mut updated = initial;
|
||||
updated.host = dir.join("updated.db").to_string_lossy().to_string();
|
||||
let sync = sync_connection_configs(&state, std::slice::from_ref(&updated)).await;
|
||||
|
||||
assert_eq!(sync.connection_pool_ids_to_drop.as_slice(), &["conn-a".to_string()]);
|
||||
assert!(!state.app.session_credentials.has("token-a", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has("token-b", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has_pool_owner("conn-a"));
|
||||
assert!(state.app.session_credentials.pool_owner_mismatch("conn-a", "token-b"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_connection_configs_clears_all_owner_credentials_on_delete_and_id_reuse() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let mut initial = sqlite_config("conn-a", &dir.join("old.db").to_string_lossy());
|
||||
initial.save_password = false;
|
||||
state.app.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||
let _ = state.app.session_credentials.set("token-a", "conn-a", "owner-a-secret");
|
||||
let _ = state.app.session_credentials.set("token-b", "conn-a", "owner-b-secret");
|
||||
state.app.session_credentials.record_pool_owner("conn-a", "token-a");
|
||||
|
||||
let deleted = sync_connection_configs(&state, &[]).await;
|
||||
assert_eq!(deleted.connection_pool_ids_to_drop.as_slice(), &["conn-a".to_string()]);
|
||||
assert!(!state.app.session_credentials.has("token-a", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has("token-b", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has_pool_owner("conn-a"));
|
||||
assert!(state.app.session_credentials.pool_owner_mismatch("conn-a", "token-b"));
|
||||
|
||||
let _ = state.app.session_credentials.set("token-a", "conn-a", "stale-owner-a-secret");
|
||||
let _ = state.app.session_credentials.set("token-b", "conn-a", "stale-owner-b-secret");
|
||||
state.app.session_credentials.record_pool_owner("conn-a", "token-a");
|
||||
let mut recreated = sqlite_config("conn-a", &dir.join("new.db").to_string_lossy());
|
||||
recreated.save_password = false;
|
||||
|
||||
let reused =
|
||||
mcp_add_connection(State(state.clone()), Json(McpAddConnectionRequest { config: recreated.clone() }))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{}", error.message));
|
||||
assert_eq!(reused.0.host, recreated.host);
|
||||
assert!(!state.app.session_credentials.has("token-a", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has("token-b", "conn-a"));
|
||||
assert!(!state.app.session_credentials.has_pool_owner("conn-a"));
|
||||
assert!(state.app.session_credentials.pool_owner_mismatch("conn-a", "token-b"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn save_connections_removes_deleted_runtime_config_and_mq_adapter() {
|
||||
@@ -1169,8 +1496,12 @@ mod tests {
|
||||
}
|
||||
let stale = state.app.mq_registry.get_or_build(&removed).await.unwrap().adapter;
|
||||
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
||||
let result = save_connections(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(SaveConnectionsRequest { configs: vec![kept.clone()] }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
@@ -1197,8 +1528,12 @@ mod tests {
|
||||
}
|
||||
state.app.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
|
||||
|
||||
let result =
|
||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
||||
let result = save_connections(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(SaveConnectionsRequest { configs: vec![kept.clone()] }),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert!(!state.app.connections.read().await.contains_key(&removed.id));
|
||||
|
||||
Reference in new issue
Block a user