diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 7b9ca75044..9afd4d081a 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -385,6 +385,8 @@ const { disconnectConnection, connectionDisconnectMenuLabel, canDisconnectConnection, + canForgetSessionCredential, + disconnectAndForgetConnectionPassword, cancelConnectionAttempt, closeDatabaseConnection, isPinned, @@ -4433,6 +4435,15 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X }); } else if (canDisconnectConnection()) { items.push({ label: connectionDisconnectMenuLabel(), action: disconnectConnection, icon: Unplug }); + // save_password=false 且本次运行期已输入密码:提供"断开并忘记本次密码", + // 清除会话凭据后下次连接需重新输入。 + if (canForgetSessionCredential()) { + items.push({ + label: t("connection.disconnectAndForgetPassword"), + action: disconnectAndForgetConnectionPassword, + icon: Unplug, + }); + } } else if (!isConnected.value) { items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug }); } diff --git a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts index 1863d8cc87..4186511e81 100644 --- a/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts @@ -246,6 +246,26 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu return connectionDisconnectTargets().some((target) => connectionStore.connectedIds.has(target.connectionId)); } + /** "断开并忘记本次密码"是否可用:save_password=false 且当前已连接(本次运行期必已输入密码)。 */ + function canForgetSessionCredential(): boolean { + const node = activeNode.value; + if (!node?.connectionId) return false; + const config = connectionStore.getConfig(node.connectionId); + return config?.save_password === false && connectionStore.connectedIds.has(node.connectionId); + } + + /** "断开并忘记本次密码":关闭连接池并清除该连接本次运行期的会话密码。 */ + async function disconnectAndForgetConnectionPassword() { + const node = activeNode.value; + if (!node?.connectionId) return; + try { + await connectionStore.disconnectAndForgetConnectionPassword(node.connectionId); + toast(t("connection.passwordForgotten"), 2000); + } catch (error: any) { + toast(t("connection.saveFailed", { message: error?.message || String(error) }), 5000); + } + } + async function cancelConnectionAttempt() { const connectionId = activeNode.value.connectionId; if (!connectionId) return; @@ -366,6 +386,8 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu disconnectConnection, connectionDisconnectMenuLabel, canDisconnectConnection, + canForgetSessionCredential, + disconnectAndForgetConnectionPassword, cancelConnectionAttempt, closeDatabaseConnection, isPinned, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4607eacd81..f991f0452f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -444,7 +444,9 @@ export default { user: "User", password: "Password", savePassword: "Save password", - savePasswordHint: "Uncheck to require entering it on every connect", + savePasswordHint: "Not saved locally. Kept only for this session and cleared when you quit the app", + disconnectAndForgetPassword: "Disconnect and Forget Password", + passwordForgotten: "Password forgotten. Reconnect will ask for it again.", promptPasswordTitle: "Enter password", promptPasswordMessage: "Enter the password for connection “{connection}”.", promptPasswordPlaceholder: "Password", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f78db2163f..8c98751cb8 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -445,7 +445,9 @@ export default withEnglishFallback({ user: "Usuario", password: "Contraseña", savePassword: "Guardar contraseña", - savePasswordHint: "Desmarcar para solicitarla en cada conexión", + savePasswordHint: "No se guarda localmente. Solo se conserva en esta sesión y se borra al salir de la aplicación", + disconnectAndForgetPassword: "Desconectar y olvidar la contraseña", + passwordForgotten: "Contraseña olvidada. La próxima conexión la solicitará de nuevo.", promptPasswordTitle: "Introducir contraseña", promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”.", promptPasswordPlaceholder: "Contraseña", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 84a1179daf..54d25c0e82 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -444,7 +444,9 @@ export default withEnglishFallback({ user: "Utente", password: "Password", savePassword: "Salva password", - savePasswordHint: "Deseleziona per richiederla a ogni connessione", + savePasswordHint: "Non salvata localmente. Conservata solo per questa sessione e cancellata alla chiusura dell'app", + disconnectAndForgetPassword: "Disconnetti e dimentica la password", + passwordForgotten: "Password dimenticata. La prossima connessione la richiederà di nuovo.", promptPasswordTitle: "Inserisci password", promptPasswordMessage: "Inserisci la password per la connessione “{connection}”.", promptPasswordPlaceholder: "Password", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 26b60ca4da..9a479d3806 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -444,7 +444,9 @@ export default withEnglishFallback({ user: "ユーザー", password: "パスワード", savePassword: "パスワードを保存", - savePasswordHint: "チェックを外すと、接続のたびに入力が必要です", + savePasswordHint: "ローカルには保存しません。初回接続後、パスワードはこの実行中のみ保持され、アプリ終了時に消去されます。", + disconnectAndForgetPassword: "切断してこのパスワードを忘れる", + passwordForgotten: "パスワードを忘れました。次回の接続で再入力が必要です。", promptPasswordTitle: "パスワードを入力", promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。", promptPasswordPlaceholder: "パスワード", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index d42328cd85..55a878fdf9 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -438,7 +438,9 @@ export default withEnglishFallback({ user: "사용자", password: "비밀번호", savePassword: "비밀번호 저장", - savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다", + savePasswordHint: "로컬에 저장하지 않습니다. 첫 연결 후 비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.", + disconnectAndForgetPassword: "연결 끊고 비밀번호 잊기", + passwordForgotten: "비밀번호를 잊었습니다. 다음 연결 시 다시 입력해야 합니다.", promptPasswordTitle: "비밀번호 입력", promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.", promptPasswordPlaceholder: "비밀번호", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0321211d07..631239d784 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -445,7 +445,9 @@ export default withEnglishFallback({ user: "Usuário", password: "Senha", savePassword: "Salvar senha", - savePasswordHint: "Desmarque para exigir o preenchimento em cada conexão", + savePasswordHint: "Não salva localmente. Mantida apenas nesta sessão e apagada ao sair do aplicativo", + disconnectAndForgetPassword: "Desconectar e Esquecer a Senha", + passwordForgotten: "Senha esquecida. A próxima conexão pedirá novamente.", promptPasswordTitle: "Digite a senha", promptPasswordMessage: "Digite a senha da conexão “{connection}”.", promptPasswordPlaceholder: "Senha", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8508146f13..2a95484fe0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -369,7 +369,9 @@ export default withEnglishFallback({ user: "用户名", password: "密码", savePassword: "本地保存密码", - savePasswordHint: "取消勾选后,每次连接都需要输入密码", + savePasswordHint: "不保存到本地。首次连接后,密码仅在本次运行期间保留;退出应用后将清除。", + disconnectAndForgetPassword: "断开并忘记本次密码", + passwordForgotten: "已忘记密码,下次连接需重新输入。", promptPasswordTitle: "输入密码", promptPasswordMessage: "请输入连接“{connection}”的密码。", promptPasswordPlaceholder: "密码", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9fe21bd073..6a82b555f0 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -445,7 +445,9 @@ export default withEnglishFallback({ user: "使用者名稱", password: "密碼", savePassword: "本機儲存密碼", - savePasswordHint: "取消勾選後,每次連線都需要輸入密碼", + savePasswordHint: "不儲存到本機。首次連線後,密碼僅在本次執行期間保留;退出應用程式後將清除。", + disconnectAndForgetPassword: "中斷連線並忘記本次密碼", + passwordForgotten: "已忘記密碼,下次連線需重新輸入。", promptPasswordTitle: "輸入密碼", promptPasswordMessage: "請輸入連線「{connection}」的密碼。", promptPasswordPlaceholder: "密碼", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 5d1ce6c955..b1362be6d4 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -58,6 +58,8 @@ export const connectionDatabaseInfo = forward("connectionDatabaseInfo"); export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo"); export const connectionFinalProxyPort = forward("connectionFinalProxyPort"); export const disconnectDb = forward("disconnectDb"); +export const sessionCredentialStatus = forward("sessionCredentialStatus"); +export const forgetSessionCredential = forward("forgetSessionCredential"); export const checkConnectionHealth = forward("checkConnectionHealth"); export const connectionIdentifierQuote = forward("connectionIdentifierQuote"); export const closeDatabaseConnection = forward("closeDatabaseConnection"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 628b479cc4..397244403c 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -373,6 +373,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number) return post("/api/connection/disconnect", { connectionId, clientAttempt }); } +export async function sessionCredentialStatus(connectionId: string): Promise { + return post("/api/connection/session-credential-status", { connectionId }); +} + +export async function forgetSessionCredential(connectionId: string): Promise { + return post("/api/connection/forget-session-credential", { connectionId }); +} + export async function checkConnectionHealth(connectionId: string): Promise { return post("/api/connection/check-health", { connectionId }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index ab843bb591..692e54fcb9 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -924,6 +924,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number) return invokeBackend("disconnect_db", { connectionId, clientAttempt }); } +export async function sessionCredentialStatus(connectionId: string): Promise { + return invokeBackend("session_credential_status", { connectionId }); +} + +export async function forgetSessionCredential(connectionId: string): Promise { + return invokeBackend("forget_session_credential", { connectionId }); +} + export async function checkConnectionHealth(connectionId: string): Promise { return invokeBackend("check_connection_health", { connectionId }); } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts index aaec503931..c0bb78fd75 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.savePassword.spec.ts @@ -44,12 +44,15 @@ function installApiMocks(extra: Record = {}) { vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), connectDb: vi.fn().mockResolvedValue("pg-1"), + disconnectDb: vi.fn().mockResolvedValue(undefined), deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), loadSchemaCache: vi.fn().mockResolvedValue(null), saveConnections: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined), listInstalledAgents: vi.fn().mockResolvedValue([]), + sessionCredentialStatus: vi.fn().mockResolvedValue(false), + forgetSessionCredential: vi.fn().mockResolvedValue(undefined), ...extra, })); } @@ -157,6 +160,58 @@ describe("connectionStore save_password opt-out", () => { expect(requestPassword).not.toHaveBeenCalled(); }); + it("connect skips the prompt when the backend already has a session credential", async () => { + installApiMocks({ sessionCredentialStatus: vi.fn().mockResolvedValue(true) }); + installPasswordPromptMock(); + requestPassword.mockResolvedValue({ password: "never-used", rememberPassword: false }); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" }); + store.connections = [connection]; + + await store.connect(connection); + + // 本次运行期已输入过密码:不再弹窗,直接以空密码 connectDb(后端补主密码)。 + expect(requestPassword).not.toHaveBeenCalled(); + const { connectDb } = await import("@/lib/backend/api"); + expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "pg-1", password: "" }), expect.any(Number)); + }); + + it("disconnectAndForgetConnectionPassword clears the backend session credential", async () => { + const forgetSessionCredential = vi.fn().mockResolvedValue(undefined); + installApiMocks({ + forgetSessionCredential, + sessionCredentialStatus: vi.fn().mockResolvedValue(true), + }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" }); + store.connections = [connection]; + store.connectedIds.add("pg-1"); + + await store.disconnectAndForgetConnectionPassword("pg-1"); + + expect(forgetSessionCredential).toHaveBeenCalledWith("pg-1"); + expect(store.connectedIds.has("pg-1")).toBe(false); + }); + + it("disconnectAndForgetConnectionPassword propagates forget failures", async () => { + const forgetSessionCredential = vi.fn().mockRejectedValue(new Error("no session credential")); + installApiMocks({ + forgetSessionCredential, + sessionCredentialStatus: vi.fn().mockResolvedValue(true), + }); + installPasswordPromptMock(); + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" }); + store.connections = [connection]; + store.connectedIds.add("pg-1"); + + await expect(store.disconnectAndForgetConnectionPassword("pg-1")).rejects.toThrow("no session credential"); + }); + it("prompts and retries when MySQL rejects a synced connection that sent no password", async () => { const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1"); installApiMocks({ connectDb }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2ba3eddb15..65c5f2e831 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -3054,6 +3054,23 @@ export const useConnectionStore = defineStore("connection", () => { } } + /** + * Query whether this connection already has a transient password stored in the + * backend session-credential store (a `save_password === false` connection that + * was connected once this run). Used to skip the interactive password prompt on + * later connects. Only returns a boolean — the password itself never leaves the + * backend process memory. + */ + async function hasSessionCredential(connectionId: string): Promise { + try { + return await api.sessionCredentialStatus(connectionId); + } catch { + // Status query failure is treated as "no credential": falling back to the + // interactive prompt is the safe, well-known path. + return false; + } + } + /** * Prompt for a transient password when saving is disabled, or when a server * confirms that a metadata-only synced connection sent no password. The @@ -3109,9 +3126,13 @@ export const useConnectionStore = defineStore("connection", () => { try { let rememberPassword = false; if (config.save_password === false && !config.password) { - const prompted = await ensureConnectionPassword(config); - config = prompted.config; - rememberPassword = prompted.rememberPassword; + // 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗, + // 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。 + if (!(await hasSessionCredential(config.id))) { + const prompted = await ensureConnectionPassword(config); + config = prompted.config; + rememberPassword = prompted.rememberPassword; + } } await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { @@ -3243,6 +3264,16 @@ export const useConnectionStore = defineStore("connection", () => { } } + /** + * "断开并忘记本次密码":关闭连接池(保留其它已保存连接的凭据),并清除该 + * `save_password === false` 连接本次运行期的会话密码,使下一次连接必须重新输入。 + * 若该连接本无会话凭据,后端会返回错误并在此抛出(不静默吞掉),避免误报成功。 + */ + async function disconnectAndForgetConnectionPassword(connectionId: string) { + await disconnect(connectionId); + await api.forgetSessionCredential(connectionId); + } + async function closeDatabaseConnection(connectionId: string, database: string) { if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database); await api.closeDatabaseConnection(connectionId, database); @@ -3310,9 +3341,13 @@ export const useConnectionStore = defineStore("connection", () => { // in-flight dedup above keeps its exact microtask cadence; only await the // interactive prompt when the connection actually needs a typed password. if (config.save_password === false && !config.password) { - const prompted = await ensureConnectionPassword(config); - config = prompted.config; - rememberPassword = prompted.rememberPassword; + // 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗, + // 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。 + if (!(await hasSessionCredential(connectionId))) { + const prompted = await ensureConnectionPassword(config); + config = prompted.config; + rememberPassword = prompted.rememberPassword; + } } await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { @@ -7816,6 +7851,8 @@ export const useConnectionStore = defineStore("connection", () => { connect, cancelConnecting, disconnect, + disconnectAndForgetConnectionPassword, + hasSessionCredential, closeDatabaseConnection, ensureConnected, loadConnectedConnectionRootForSidebarSearch, diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 941247d6ea..57d1bf9e68 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -29,6 +29,7 @@ use crate::models::connection::{ use crate::path_utils::expand_tilde; use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv}; use crate::query_cancel::RunningQueries; +use crate::session_credentials::SessionCredentialStore; use crate::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORKER_MAX_PROCESSES_DEFAULT}; use crate::task_supervisor::TaskSupervisor; @@ -224,6 +225,11 @@ pub struct AppState { /// Used to reconstruct a TLS connector compatible with the original connection when cancelling. postgres_cancel_contexts: Arc>>, pub transaction_sessions: Arc>>, + /// `save_password=false` 连接本次运行期的临时密码(内存,进程退出即丢, + /// 绝不落盘)。键为 `(owner_scope, connection_id)`:桌面端 owner 为空串, + /// Web 端 owner 为已认证会话 token,不同登录会话互不可见。建池/池重建/ + /// AI/元数据从它读取,前端通过状态接口查询。 + pub session_credentials: SessionCredentialStore, #[cfg(feature = "mq-admin")] pub mq_registry: crate::mq::MqAdminRegistry, } @@ -960,6 +966,43 @@ impl AppState { } } + /// save_password=false 连接:持久化/运行态 config 的 password 恒为空,从本次 + /// 运行期会话凭据仓库补主密码,使手动/编辑器/AI/元数据/池重建复用首次输入, + /// 不再反复弹窗。会话凭据只在内存中,进程退出即丢;"断开并忘记"后仓库为空, + /// 此处自然回到空密码(需重新输入)。 + /// + /// owner 作用域取自 [`crate::session_credentials::current_credential_owner`]: + /// Web 请求由鉴权中间件注入会话 token,桌面端/后台任务为空串。这样不同登录 + /// 会话只会读取各自输入的密码,不会跨会话复用。 + pub fn apply_session_credential( + &self, + config: &ConnectionConfig, + db_config: &mut ConnectionConfig, + connection_id: &str, + ) { + if !config.save_password && db_config.password.is_empty() { + let owner = crate::session_credentials::current_credential_owner().unwrap_or_default(); + if let Some(session_password) = self.session_credentials.get(&owner, connection_id) { + db_config.password = session_password; + } + } + } + + /// no-save 连接复用已有池时,若该池由"另一个 owner 会话"创建,则不应复用 + /// (否则会话 B 会直接使用会话 A 输入的临时密码建立的连接)。返回 `true` + /// 表示需要销毁旧池、以当前 owner 的凭据重建。 + async fn pool_credential_owner_mismatch(&self, config: &ConnectionConfig, pool_key: &str) -> bool { + if config.save_password { + return false; + } + let owner = crate::session_credentials::current_credential_owner().unwrap_or_default(); + if owner.is_empty() { + // 桌面端/未注入 owner:单用户场景,按既有逻辑复用。 + return false; + } + self.session_credentials.pool_owner_mismatch(pool_key, &owner) + } + async fn handle_shared_connection_open_error( &self, error: crate::agent_runtime::SharedConnectionOpenError, @@ -1041,6 +1084,7 @@ impl AppState { duckdb_worker_max_processes: AtomicUsize::new(DUCKDB_WORKER_MAX_PROCESSES_DEFAULT), postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())), transaction_sessions: Arc::new(RwLock::new(HashMap::new())), + session_credentials: SessionCredentialStore::new(), #[cfg(feature = "mq-admin")] mq_registry: crate::mq::MqAdminRegistry::new(), } @@ -1345,6 +1389,14 @@ impl AppState { routing.detach_pool_by_key(&pool_key, true).await; return Err("Agent runtime is unavailable while publishing the connection pool".to_string()); } + // 记录 no-save 池由哪个 owner 会话创建,供多会话复用判定使用 + // (见 pool_credential_owner_mismatch)。已保存密码连接始终共享池,不记录。 + if !config.save_password { + let owner = crate::session_credentials::current_credential_owner().unwrap_or_default(); + if !owner.is_empty() { + self.session_credentials.record_pool_owner(&pool_key, &owner); + } + } Ok(()) } @@ -1735,6 +1787,11 @@ impl AppState { drop(conns); if self.remove_pool_if_duckdb_isolation_mismatch(&pool_key).await { // Recreate below using the current DuckDB isolation mode. + } else if self.pool_credential_owner_mismatch(&config, &pool_key).await { + // 创建该 no-save 池的是另一个登录会话:销毁旧池,用当前会话的 + // 凭据重建,避免跨会话复用他人输入的临时密码。 + self.remove_stale_connection_pool(&pool_key).await; + break; } else if !validate_existing_pool || !self.remove_stale_connection_pool(&pool_key).await { self.touch_pool_activity(&pool_key).await; return Ok(pool_key); @@ -1752,7 +1809,8 @@ impl AppState { break; } - let db_config = database_connection_config_with_catalog(&config, database, catalog); + let mut db_config = database_connection_config_with_catalog(&config, database, catalog); + self.apply_session_credential(&config, &mut db_config, connection_id); validate_h2_file_connection(&db_config)?; self.ensure_current_connection_attempt(connection_id, connection_attempt).await?; @@ -4192,6 +4250,7 @@ impl AppState { let pool_keys = self.connections.read().await.keys().cloned().collect::>(); self.stop_keepalive_tasks(&pool_keys).await; self.pool_activity.write().await.clear(); + self.session_credentials.clear_pool_owners(); self.postgres_cancel_contexts.write().await.clear(); self.draining_pools.lock().unwrap_or_else(|error| error.into_inner()).clear(); self.connections.write().await.drain().collect() @@ -4230,6 +4289,7 @@ impl AppState { cancel_contexts.remove(key); } } + self.session_credentials.remove_pool_owners(&keys_to_remove); let mut conns = self.connections.write().await; let mut removed = Vec::with_capacity(keys_to_remove.len()); for key in keys_to_remove { @@ -4718,6 +4778,26 @@ fn session_scoped_pool_key_for( session_scoped_pool_key(base_pool_key, client_session_id) } +/// 两个运行态连接配置是否视为同一连接(用于决定是否销毁连接池)。 +/// +/// 仅当双方都是 `save_password=false` 时才忽略 `password` 字段的差异:这类连接 +/// 在 connect 时运行态配置可能携带会话密码,持久化同步后为空,这种空值差异不应 +/// 触发池重建。若任一方 `save_password=true`,密码是真实的连接参数,任何密码变更 +/// (包括用户保存了新密码)都必须销毁旧池,否则旧池会继续用旧密码认证。 +pub fn connection_configs_pool_equivalent(a: &ConnectionConfig, b: &ConnectionConfig) -> bool { + if a == b { + return true; + } + if !a.save_password && !b.save_password { + let mut a = a.clone(); + a.password.clear(); + let mut b = b.clone(); + b.password.clear(); + return a == b; + } + false +} + fn pool_key_for_session_role( config: Option<&ConnectionConfig>, base_pool_key: String, @@ -5161,15 +5241,16 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq #[cfg(test)] mod tests { use super::{ - agent_connect_timeout, connection_probe_endpoints, connection_remote_endpoint, connection_url_for_endpoint, - database_connection_config, database_connection_config_with_catalog, - gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, - kafka_single_loopback_bootstrap_endpoint, metadata_connection_config, mysql_metadata_fallback_url, - mysql_pool_setup_queries, oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, - prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id, - redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, - sqlserver_uses_legacy_driver, task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool, - uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, + agent_connect_timeout, connection_configs_pool_equivalent, connection_probe_endpoints, + connection_remote_endpoint, connection_url_for_endpoint, database_connection_config, + database_connection_config_with_catalog, gaussdb_identifier_quote_from_query_result, + gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, kafka_single_loopback_bootstrap_endpoint, + metadata_connection_config, mysql_metadata_fallback_url, mysql_pool_setup_queries, + oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, + redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix, + sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, + task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, + validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ @@ -5280,6 +5361,172 @@ mod tests { assert_eq!(metadata_connection_config(&config).database, None); } + #[test] + fn connection_configs_pool_equivalent_ignores_password_differences() { + let mut a = mysql_config(None); + a.save_password = false; + a.password = "session-secret".to_string(); + // 持久化同步后 password 为空(save_password=false):视为同一连接,不销毁池。 + let mut b = a.clone(); + b.password.clear(); + assert!(connection_configs_pool_equivalent(&a, &b)); + assert!(connection_configs_pool_equivalent(&b, &a)); + + // 两个非空但不同的密码:同样忽略(密码不应触发池重建)。 + let mut c = a.clone(); + c.password = "changed-secret".to_string(); + assert!(connection_configs_pool_equivalent(&a, &c)); + } + + #[test] + fn connection_configs_pool_equivalent_detects_saved_password_change() { + let mut a = mysql_config(None); + a.save_password = true; + a.password = "old-secret".to_string(); + let mut b = a.clone(); + b.password = "new-secret".to_string(); + // save_password=true:密码是真实连接参数,保存新密码后旧池不得继续用旧密码认证。 + assert!(!connection_configs_pool_equivalent(&a, &b)); + assert!(!connection_configs_pool_equivalent(&b, &a)); + } + + #[test] + fn connection_configs_pool_equivalent_detects_real_parameter_changes() { + let mut a = mysql_config(None); + a.password = "secret".to_string(); + // host / port / username / database 等真实连接参数变化应视为不同连接(销毁池)。 + let mut host = a.clone(); + host.host = "other-host".to_string(); + assert!(!connection_configs_pool_equivalent(&a, &host)); + + let mut port = a.clone(); + port.port = 5433; + assert!(!connection_configs_pool_equivalent(&a, &port)); + + let mut user = a.clone(); + user.username = "other-user".to_string(); + assert!(!connection_configs_pool_equivalent(&a, &user)); + + let mut ssl = a.clone(); + ssl.ssl = true; + assert!(!connection_configs_pool_equivalent(&a, &ssl)); + + // 完全相等(含相同密码)→ true。 + assert!(connection_configs_pool_equivalent(&a, &a.clone())); + } + + #[tokio::test] + async fn apply_session_credential_injects_saved_password_only_for_no_save_connections() { + let dir = std::env::temp_dir().join(format!("dbx-core-session-cred-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.join("plugins")); + let _ = state.session_credentials.set("", "conn-a", "s3cret"); + + let mut config = mysql_config(None); + config.id = "conn-a".to_string(); + config.save_password = false; + config.password.clear(); + + // save_password=false + db_config 密码为空 → 从会话凭据仓库注入。 + let mut db_config = metadata_connection_config(&config); + state.apply_session_credential(&config, &mut db_config, &config.id); + assert_eq!(db_config.password, "s3cret"); + + // save_password=true → 不注入(走持久化水合的密码,若为空则保持空)。 + config.save_password = true; + let mut db_config = metadata_connection_config(&config); + state.apply_session_credential(&config, &mut db_config, &config.id); + assert_eq!(db_config.password, ""); + + // 无会话凭据 → 保持空密码("断开并忘记"后重新输入)。 + state.session_credentials.remove("", "conn-a"); + config.save_password = false; + let mut db_config = metadata_connection_config(&config); + state.apply_session_credential(&config, &mut db_config, &config.id); + assert_eq!(db_config.password, ""); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn apply_session_credential_reads_owner_scoped_credentials_only() { + let dir = std::env::temp_dir().join(format!("dbx-core-session-cred-owner-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.join("plugins")); + + let mut config = mysql_config(None); + config.id = "conn-a".to_string(); + config.save_password = false; + config.password.clear(); + + // 会话 X 输入了密码,会话 Y 未输入:Y 的请求(owner=token-y)不得注入 X 的密码。 + let _ = state.session_credentials.set("token-x", "conn-a", "x-secret"); + crate::session_credentials::with_credential_owner(Some("token-y".to_string()), async { + let mut db_config = metadata_connection_config(&config); + state.apply_session_credential(&config, &mut db_config, &config.id); + assert_eq!(db_config.password, ""); + }) + .await; + + // 会话 X 自身能读到自己的密码。 + crate::session_credentials::with_credential_owner(Some("token-x".to_string()), async { + let mut db_config = metadata_connection_config(&config); + state.apply_session_credential(&config, &mut db_config, &config.id); + assert_eq!(db_config.password, "x-secret"); + }) + .await; + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn pool_credential_owner_mismatch_prevents_cross_session_pool_reuse() { + let dir = std::env::temp_dir().join(format!("dbx-core-pool-owner-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.join("plugins")); + + let mut config = mysql_config(None); + config.id = "conn-a".to_string(); + config.save_password = false; + + // 模拟会话 X 创建的 no-save 池。 + state.session_credentials.record_pool_owner("conn-a", "token-x"); + + // 同一会话 X 复用 → 无冲突。 + crate::session_credentials::with_credential_owner(Some("token-x".to_string()), async { + assert!(!state.pool_credential_owner_mismatch(&config, "conn-a").await); + }) + .await; + + // 另一会话 Y 请求同一 no-save 池 → 冲突,需以 Y 的凭据重建,避免复用 X 的密码。 + crate::session_credentials::with_credential_owner(Some("token-y".to_string()), async { + assert!(state.pool_credential_owner_mismatch(&config, "conn-a").await); + }) + .await; + + // owner 标记缺失时按不可信处理,避免全局配置失效与异步移除旧池之间复用旧池。 + state.session_credentials.clear_connection("conn-a"); + crate::session_credentials::with_credential_owner(Some("token-x".to_string()), async { + assert!(state.pool_credential_owner_mismatch(&config, "conn-a").await); + }) + .await; + + // 桌面端(无 owner)单用户 → 不冲突,按既有逻辑复用。 + assert!(!state.pool_credential_owner_mismatch(&config, "conn-a").await); + + // 已保存密码连接始终共享池 → 不冲突。 + config.save_password = true; + crate::session_credentials::with_credential_owner(Some("token-y".to_string()), async { + assert!(!state.pool_credential_owner_mismatch(&config, "conn-a").await); + }) + .await; + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn task_client_session_ids_are_stable_and_isolated() { assert_eq!(task_client_session_id("table-export", "job-1"), "table-export:job-1"); diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index bb3a3b083a..c8ace7f311 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -74,6 +74,7 @@ pub mod saved_sql; pub mod schema; pub mod schema_diff; pub mod script_generator; +pub mod session_credentials; pub mod sql; pub mod sql_analysis; pub mod sql_diagnostics; diff --git a/crates/dbx-core/src/session_credentials.rs b/crates/dbx-core/src/session_credentials.rs new file mode 100644 index 0000000000..ccf3c4629c --- /dev/null +++ b/crates/dbx-core/src/session_credentials.rs @@ -0,0 +1,348 @@ +//! 运行期会话凭据仓库。 +//! +//! 为 `save_password=false` 的连接在本次进程内临时保留主密码,使手动操作、 +//! 编辑器 SQL、AI 工具、元数据请求以及池重建都能复用首次输入的密码, +//! 无需反复弹窗。 +//! +//! 与持久化 secret store([`crate::connection_secrets::FileSecretStore`])职责分离: +//! 持久层保存"已保存密码"(save_password=true);本仓库只保存"本次运行期临时密码" +//! (save_password=false,进程退出即丢,绝不落盘)。 +//! +//! 约束: +//! - 只在 Rust 进程内存中存在,进程退出自然丢失; +//! - 不写 SQLite、云同步导出、日志或任何磁盘存储; +//! - [`fmt::Debug`] 只暴露凭据数量,不输出 owner、连接 ID 或密码值,避免调试日志与异常文本泄露。 +//! +//! # 多会话隔离(Web) +//! +//! 凭据按 `(owner_scope, connection_id)` 双键存储:桌面端(Tauri)以空字符串作为 +//! owner;Web 端以已认证会话 token 作为 owner,使不同登录会话无法复用彼此的临时 +//! 密码,登出也只清除当前会话的凭据([`SessionCredentialStore::clear_owner`])。 +//! +//! owner 通过 [`tokio::task_local`] 在请求边界注入(见 [`with_credential_owner`] / +//! [`current_credential_owner`]),因此 dbx-core 深层的池创建无需在每个函数签名上 +//! 逐层透传 owner。未注入 owner 的调用(桌面端、后台任务)按空 owner 处理,天然 +//! 隔离于任何 Web 会话凭据;即使 owner 因后台任务丢失,也会因键不匹配而"失败闭合" +//! (读不到任何会话密码),不会跨会话泄露。 + +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::sync::RwLock; + +/// 桌面端(Tauri)使用的 owner 作用域:无认证会话概念,单用户。 +pub const DESKTOP_OWNER: &str = ""; + +#[derive(Clone, Eq, Hash, PartialEq)] +struct CredentialKey { + owner_scope: String, + connection_id: String, +} + +impl CredentialKey { + fn new(owner_scope: &str, connection_id: &str) -> Self { + Self { owner_scope: owner_scope.to_string(), connection_id: connection_id.to_string() } + } +} + +struct SessionCredential { + password: String, + generation: u64, +} + +#[derive(Default)] +struct SessionCredentialState { + credentials: HashMap, + pool_credential_owners: HashMap, + next_generation: u64, +} + +pub struct SessionCredentialWriteToken { + key: CredentialKey, + generation: u64, +} + +/// 当前请求的认证会话作用域(Web 会话 token / 桌面端空串)。 +/// +/// 由 Web 鉴权中间件在请求边界通过 [`with_credential_owner`] 注入;未注入(桌面端、 +/// 后台任务、中间件未覆盖的路径)返回 `None`,调用方按空 owner 处理。 +pub fn current_credential_owner() -> Option { + CREDENTIAL_OWNER.try_get().ok().flatten() +} + +/// 在一个 future 的整个执行期间设置凭据 owner 作用域。 +/// +/// Web 鉴权中间件用它包裹下游处理器,使请求处理任务内(含其 await 到的池创建) +/// 都能读到当前会话 owner。未被此函数包裹的调用(桌面端、独立后台任务)等价于 +/// 空 owner。 +pub async fn with_credential_owner(owner_scope: Option, future: F) -> T +where + F: Future, +{ + CREDENTIAL_OWNER.scope(owner_scope, future).await +} + +tokio::task_local! { + static CREDENTIAL_OWNER: Option; +} + +/// 内存会话凭据仓库:`(owner_scope, connection_id) -> password`。 +#[derive(Default)] +pub struct SessionCredentialStore { + state: RwLock, +} + +impl SessionCredentialStore { + pub fn new() -> Self { + Self::default() + } + + /// 记录一个 no-save 连接在本次运行期输入的密码。 + /// + /// 空密码是 no-op(不覆盖已有凭据,也不删除),删除只能通过 [`Self::remove`] + /// 显式触发("断开并忘记本次密码")。这样连接成功后以空密码 config 建池 + /// 不会误清已记录的凭据。 + pub fn set(&self, owner_scope: &str, connection_id: &str, password: &str) -> Option { + if password.is_empty() { + return None; + } + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.next_generation = state.next_generation.checked_add(1).expect("session credential generation overflow"); + let key = CredentialKey::new(owner_scope, connection_id); + let generation = state.next_generation; + state.credentials.insert(key.clone(), SessionCredential { password: password.to_string(), generation }); + Some(SessionCredentialWriteToken { key, generation }) + } + + /// 读取某个 owner 作用域下的本次运行期临时密码;不存在则返回 `None`。 + pub fn get(&self, owner_scope: &str, connection_id: &str) -> Option { + let state = self.state.read().unwrap_or_else(|error| error.into_inner()); + state.credentials.get(&CredentialKey::new(owner_scope, connection_id)).map(|entry| entry.password.clone()) + } + + /// 某个 owner 作用域下是否存在本次运行期临时密码。 + pub fn has(&self, owner_scope: &str, connection_id: &str) -> bool { + self.get(owner_scope, connection_id).is_some() + } + + /// 清除某个 owner 作用域下的临时密码(连接删除 / "断开并忘记本次密码")。 + pub fn remove(&self, owner_scope: &str, connection_id: &str) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.credentials.remove(&CredentialKey::new(owner_scope, connection_id)); + } + + /// 仅当指定写入仍是当前值时删除,用于连接失败回滚,避免旧 attempt 删除更新密码。 + pub fn remove_if_current(&self, token: &SessionCredentialWriteToken) -> bool { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + let is_current = + state.credentials.get(&token.key).is_some_and(|credential| credential.generation == token.generation); + if is_current { + state.credentials.remove(&token.key); + } + is_current + } + + /// 清除某个 owner 作用域下的全部凭据(Web 登出 / 登录会话失效),不影响其他 + /// 登录会话的凭据。 + pub fn clear_owner(&self, owner_scope: &str) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.credentials.retain(|key, _| key.owner_scope != owner_scope); + } + + /// 全局连接配置被编辑、删除或复用 ID 时,原子清除所有 owner 的临时凭据和 + /// 该连接的全部池 owner 标记,防止旧密码或旧池流入新的连接定义。 + pub fn clear_connection(&self, connection_id: &str) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.credentials.retain(|key, _| key.connection_id != connection_id); + let pool_prefix = format!("{connection_id}:"); + state + .pool_credential_owners + .retain(|pool_key, _| pool_key != connection_id && !pool_key.starts_with(&pool_prefix)); + } + + /// 清空全部会话凭据(桌面端退出前兜底;Web 全实例重置)。 + pub fn clear(&self) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.credentials.clear(); + } + + pub fn record_pool_owner(&self, pool_key: &str, owner_scope: &str) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.pool_credential_owners.insert(pool_key.to_string(), owner_scope.to_string()); + } + + pub fn pool_owner_mismatch(&self, pool_key: &str, owner_scope: &str) -> bool { + let state = self.state.read().unwrap_or_else(|error| error.into_inner()); + state.pool_credential_owners.get(pool_key).is_none_or(|recorded| recorded != owner_scope) + } + + pub fn has_pool_owner(&self, pool_key: &str) -> bool { + let state = self.state.read().unwrap_or_else(|error| error.into_inner()); + state.pool_credential_owners.contains_key(pool_key) + } + + pub fn remove_pool_owners(&self, pool_keys: &[String]) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + for pool_key in pool_keys { + state.pool_credential_owners.remove(pool_key); + } + } + + pub fn clear_pool_owners(&self) { + let mut state = self.state.write().unwrap_or_else(|error| error.into_inner()); + state.pool_credential_owners.clear(); + } + + fn credential_count(&self) -> usize { + let state = self.state.read().unwrap_or_else(|error| error.into_inner()); + state.credentials.len() + } +} + +impl fmt::Debug for SessionCredentialStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SessionCredentialStore").field("credential_count", &self.credential_count()).finish() + } +} + +#[cfg(test)] +mod tests { + use super::SessionCredentialStore; + use std::sync::{Arc, Barrier}; + + #[test] + fn set_get_has_round_trip() { + let store = SessionCredentialStore::new(); + assert!(!store.has("", "conn-a")); + assert_eq!(store.get("", "conn-a"), None); + + let _ = store.set("", "conn-a", "s3cret"); + assert!(store.has("", "conn-a")); + assert_eq!(store.get("", "conn-a").as_deref(), Some("s3cret")); + } + + #[test] + fn set_with_empty_password_is_noop_and_keeps_existing() { + let store = SessionCredentialStore::new(); + let _ = store.set("", "conn-a", "s3cret"); + let empty_write = store.set("", "conn-a", ""); + assert!(empty_write.is_none()); + assert_eq!(store.get("", "conn-a").as_deref(), Some("s3cret")); + } + + #[test] + fn remove_clears_credential() { + let store = SessionCredentialStore::new(); + let _ = store.set("", "conn-a", "s3cret"); + store.remove("", "conn-a"); + assert!(!store.has("", "conn-a")); + assert_eq!(store.get("", "conn-a"), None); + } + + #[test] + fn clear_removes_all_credentials() { + let store = SessionCredentialStore::new(); + let _ = store.set("", "conn-a", "secret-a"); + let _ = store.set("", "conn-b", "secret-b"); + store.clear(); + assert!(!store.has("", "conn-a")); + assert!(!store.has("", "conn-b")); + assert_eq!(store.get("", "conn-a"), None); + } + + #[test] + fn credentials_are_isolated_by_connection_id() { + let store = SessionCredentialStore::new(); + let _ = store.set("", "conn-a", "secret-a"); + let _ = store.set("", "conn-b", "secret-b"); + assert_eq!(store.get("", "conn-a").as_deref(), Some("secret-a")); + assert_eq!(store.get("", "conn-b").as_deref(), Some("secret-b")); + store.remove("", "conn-a"); + assert_eq!(store.get("", "conn-a"), None); + assert_eq!(store.get("", "conn-b").as_deref(), Some("secret-b")); + } + + #[test] + fn credentials_are_isolated_by_owner_scope() { + let store = SessionCredentialStore::new(); + let _ = store.set("token-x", "conn-a", "x-secret"); + let _ = store.set("token-y", "conn-a", "y-secret"); + // 同一连接在不同登录会话下互不可见。 + assert_eq!(store.get("token-x", "conn-a").as_deref(), Some("x-secret")); + assert_eq!(store.get("token-y", "conn-a").as_deref(), Some("y-secret")); + // 桌面端(空 owner)与任何会话 token 均隔离。 + assert!(!store.has("", "conn-a")); + assert!(!store.has("token-z", "conn-a")); + } + + #[test] + fn clear_owner_only_removes_that_sessions_credentials() { + let store = SessionCredentialStore::new(); + let _ = store.set("token-x", "conn-a", "x-secret"); + let _ = store.set("token-y", "conn-a", "y-secret"); + let _ = store.set("token-y", "conn-b", "y2-secret"); + store.clear_owner("token-y"); + // Y 登出只清除 Y 的凭据,X 与桌面端不受影响。 + assert!(!store.has("token-y", "conn-a")); + assert!(!store.has("token-y", "conn-b")); + assert!(store.has("token-x", "conn-a")); + } + + #[test] + fn debug_output_hides_password_values() { + let store = SessionCredentialStore::new(); + let _ = store.set("session-token-sensitive", "connection-sensitive", "super-secret-value"); + let debug = format!("{store:?}"); + assert!(debug.contains("credential_count: 1")); + assert!(!debug.contains("session-token-sensitive")); + assert!(!debug.contains("connection-sensitive")); + assert!(!debug.contains("super-secret-value")); + } + + #[test] + fn clear_connection_removes_all_owners_and_pool_owner_state() { + let store = SessionCredentialStore::new(); + let _ = store.set("token-x", "conn-a", "x-secret"); + let _ = store.set("token-y", "conn-a", "y-secret"); + let _ = store.set("token-y", "conn-b", "other-secret"); + store.record_pool_owner("conn-a", "token-x"); + store.record_pool_owner("conn-a:analytics", "token-y"); + store.record_pool_owner("conn-b", "token-y"); + + store.clear_connection("conn-a"); + + assert!(!store.has("token-x", "conn-a")); + assert!(!store.has("token-y", "conn-a")); + assert!(store.has("token-y", "conn-b")); + assert!(!store.has_pool_owner("conn-a")); + assert!(!store.has_pool_owner("conn-a:analytics")); + assert!(store.pool_owner_mismatch("conn-a", "token-z")); + assert!(store.pool_owner_mismatch("conn-a:analytics", "token-z")); + assert!(store.has_pool_owner("conn-b")); + assert!(store.pool_owner_mismatch("conn-b", "token-z")); + } + + #[test] + fn stale_write_token_cannot_remove_newer_credential() { + let store = Arc::new(SessionCredentialStore::new()); + let first_write_done = Arc::new(Barrier::new(2)); + let second_write_done = Arc::new(Barrier::new(2)); + let attempt_store = store.clone(); + let attempt_first_write_done = first_write_done.clone(); + let attempt_second_write_done = second_write_done.clone(); + + let attempt_a = std::thread::spawn(move || { + let token = attempt_store.set("token-x", "conn-a", "attempt-a-password").unwrap(); + attempt_first_write_done.wait(); + attempt_second_write_done.wait(); + assert!(!attempt_store.remove_if_current(&token)); + }); + + first_write_done.wait(); + let _attempt_b_token = store.set("token-x", "conn-a", "attempt-b-password").unwrap(); + second_write_done.wait(); + attempt_a.join().unwrap(); + + assert_eq!(store.get("token-x", "conn-a").as_deref(), Some("attempt-b-password")); + } +} diff --git a/crates/dbx-web/src/auth.rs b/crates/dbx-web/src/auth.rs index 721303dc37..39122902fd 100644 --- a/crates/dbx-web/src/auth.rs +++ b/crates/dbx-web/src/auth.rs @@ -196,6 +196,8 @@ pub async fn change_password( pub async fn logout(State(state): State>, req: Request) -> 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; } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 93c0ba3329..f24b13e1a5 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -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)) diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 9c55c6a7d2..683124aa19 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -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>, + headers: HeaderMap, Json(body): Json, ) -> Result, 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>, + headers: HeaderMap, Json(body): Json, ) -> Result, 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>, + headers: HeaderMap, + Json(body): Json, +) -> Result, 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>, + headers: HeaderMap, + Json(body): Json, +) -> Result, 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>, Json(body): Json, @@ -391,6 +469,7 @@ pub async fn close_database_connection( pub async fn save_connections( State(state): State>, + _headers: HeaderMap, Json(body): Json, ) -> Result, AppError> { for config in &body.configs { @@ -416,6 +495,8 @@ pub async fn mcp_add_connection( Json(body): Json, ) -> Result, 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>, + _headers: HeaderMap, Json(body): Json, ) -> Result, 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>) -> Result>, AppError> { +pub async fn load_connections( + State(state): State>, + _headers: HeaderMap, +) -> Result>, 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)); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 66ab8e81f9..8749330570 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -7,10 +7,10 @@ pub use dbx_core::agent_connection::{ oracle_error_with_driver_hint, should_retry_mongo_with_legacy_driver, }; pub use dbx_core::connection::{ - agent_connect_timeout, connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint, - gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, metadata_connection_config, - prestosql_jdbc_config_for_endpoint, probe_connection_endpoint, redacted_connection_url_for_endpoint, AppState, - MysqlMode, PoolKind, + agent_connect_timeout, connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_configs_pool_equivalent, + connection_url_for_endpoint, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, + metadata_connection_config, prestosql_jdbc_config_for_endpoint, probe_connection_endpoint, + redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind, }; use dbx_core::database_capabilities; use dbx_core::db; @@ -170,7 +170,7 @@ mod tests { use super::{ connect_sqlite_from_config, gaussdb_m_jdbc_command_config, mark_mongo_legacy_driver, mongo_legacy_connect_params, persist_mongo_legacy_driver_profile, save_connection_configs, - MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE, + sync_connection_configs, MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE, }; use dbx_core::connection::{AppState, PoolKind}; use dbx_core::models::connection::{AttachedDatabaseConfig, ConnectionConfig, DatabaseType}; @@ -593,6 +593,39 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + + #[tokio::test] + async fn sync_connection_configs_ignores_password_only_changes() { + let dir = std::env::temp_dir().join(format!("dbx-tauri-conn-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.join("plugins")); + + let mut initial = mongodb_config(); + initial.id = "conn-a".to_string(); + initial.save_password = false; + initial.password = "session-secret".to_string(); + let _ = state.session_credentials.set("", "conn-a", "session-secret"); + state.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.configs.read().await.get("conn-a").map(|c| c.password.as_str()), Some("")); + assert!(state.session_credentials.has("", "conn-a")); + + // 真实连接参数(host)变化应销毁池,并清除旧会话凭据以便重新输入。 + let mut host_changed = updated.clone(); + host_changed.host = "other-host".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.session_credentials.has("", "conn-a")); + + let _ = std::fs::remove_dir_all(dir); + } } #[tauri::command] @@ -636,6 +669,8 @@ async fn sync_connection_configs(state: &AppState, configs: &[ConnectionConfig]) true } else { connection_pool_ids_to_drop.insert(id.clone()); + // 连接已被删除:同步清理本次运行期会话凭据。 + state.session_credentials.clear_connection(id); if existing.db_type == DatabaseType::Nacos { nacos_adapter_ids_to_drop.insert(id.clone()); } @@ -659,7 +694,12 @@ async fn sync_connection_configs(state: &AppState, configs: &[ConnectionConfig]) if previous.db_type == 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) { + // 连接端点/认证参数已变:旧会话凭据不再适配,清除以便下次重新输入, + // 避免复用旧密码去连新端点而直接认证失败。 + state.session_credentials.clear_connection(&config.id); connection_pool_ids_to_drop.insert(config.id.clone()); } } @@ -1198,6 +1238,14 @@ async fn test_connection_with_info_inner( result.map(|message| ConnectionTestResult::success(message).with_database_info(database_info)) } +/// 连接成功且 `save_password=false` 时,把本次输入的密码记入内存会话凭据仓库, +/// 供本次运行内 AI / 元数据 / 池重建复用(进程退出即丢,绝不落盘)。 +fn record_session_credential(state: &AppState, config: &ConnectionConfig, connection_id: &str) { + if !config.save_password && !config.password.is_empty() { + let _ = state.session_credentials.set("", connection_id, &config.password); + } +} + #[tauri::command] pub async fn connect_db( state: State<'_, Arc>, @@ -1213,7 +1261,10 @@ pub async fn connect_db( )?; } let id = config.id.clone(); - let db_config = metadata_connection_config(&config); + let mut db_config = metadata_connection_config(&config); + // save_password=false 连接:前端在会话凭据存在时跳过弹窗并以空密码请求, + // 此处从运行期会话凭据仓库补主密码,使重连/AI 新建池不再 ORA-01005。 + state.apply_session_credential(&config, &mut db_config, &id); let attempt = state.begin_connection_attempt_with_client_attempt(&id, client_attempt).await; let mut connected_config = config.clone(); let mut connected_db_config = db_config.clone(); @@ -1313,7 +1364,12 @@ pub async fn connect_db( state.reset_connection_transport_for_config(&id, &db_config).await; return Err(err); } - state.configs.write().await.insert(id.clone(), config); + record_session_credential(state.inner(), &config, &id); + let mut stored = config; + if !stored.save_password { + stored.password.clear(); + } + state.configs.write().await.insert(id.clone(), stored); return Ok(id); } Err(e) => e, @@ -1552,7 +1608,13 @@ pub async fn connect_db( state.reset_connection_transport_for_config(&id, &connected_db_config).await; return Err(err); } - state.configs.write().await.insert(id.clone(), connected_config); + record_session_credential(state.inner(), &connected_config, &id); + // 存入全局运行态 configs 的配置脱敏(no-save 密码恒为空),明文只存在于会话凭据仓库。 + let mut stored = connected_config; + if !stored.save_password { + stored.password.clear(); + } + state.configs.write().await.insert(id.clone(), stored); Ok(id) } @@ -1576,9 +1638,16 @@ pub async fn connection_final_proxy_port( let connection_id = runtime_config.id.clone(); let db_config = metadata_connection_config(&runtime_config); - state.configs.write().await.insert(connection_id.clone(), runtime_config); + // This pre-connect path caches the configuration for tunnel resolution. Keep + // no-save passwords out of that shared runtime cache just like connect_db. + let mut stored_config = runtime_config.clone(); + if !stored_config.save_password { + stored_config.password.clear(); + } + state.configs.write().await.insert(connection_id.clone(), stored_config); let (_, port) = state.connection_host_port(&connection_id, &db_config).await?; + record_session_credential(state.inner(), &runtime_config, &connection_id); Ok(port) } @@ -1619,6 +1688,32 @@ pub async fn close_database_connection( state.close_database_pool(&connection_id, database).await } +/// 查询连接在本次运行期是否已输入并暂存密码(`save_password=false`)。 +/// 供前端决定是否需要弹密码框;仅返回布尔状态,不泄露密码本身。 +#[tauri::command] +pub async fn session_credential_status(state: State<'_, Arc>, connection_id: String) -> Result { + Ok(state.session_credentials.has("", &connection_id)) +} + +/// "断开并忘记本次密码":清除连接本次运行期的临时密码,下次连接需重新输入。 +/// 只清内存会话凭据,不影响持久化配置与已保存密码。 +#[tauri::command] +pub async fn forget_session_credential(state: State<'_, Arc>, connection_id: String) -> Result<(), String> { + if !state.session_credentials.has("", &connection_id) { + return Err(format!("Connection has no transient session credential to forget: {connection_id}")); + } + state.session_credentials.remove("", &connection_id); + Ok(()) +} + +/// 清空全部运行期会话凭据(桌面端退出前调用;Web 端登出时走 `auth.rs logout`)。 +/// 密码只存在于本次进程内存,进程退出本就会丢失;显式清除用于退出前兜底。 +#[tauri::command] +pub async fn clear_all_session_credentials(state: State<'_, Arc>) -> Result<(), String> { + state.session_credentials.clear(); + Ok(()) +} + #[tauri::command] pub async fn refresh_connections(state: State<'_, Arc>) -> Result<(), String> { state.refresh_connections().await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ae3a5b445..2166683367 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1607,6 +1607,9 @@ pub fn run() { commands::connection::connection_final_proxy_port, commands::connection::disconnect_db, commands::connection::close_database_connection, + commands::connection::session_credential_status, + commands::connection::forget_session_credential, + commands::connection::clear_all_session_credentials, commands::connection::refresh_connections, commands::connection::check_connection_health, commands::connection::connection_identifier_quote,