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
@@ -385,6 +385,8 @@ const {
|
|||||||
disconnectConnection,
|
disconnectConnection,
|
||||||
connectionDisconnectMenuLabel,
|
connectionDisconnectMenuLabel,
|
||||||
canDisconnectConnection,
|
canDisconnectConnection,
|
||||||
|
canForgetSessionCredential,
|
||||||
|
disconnectAndForgetConnectionPassword,
|
||||||
cancelConnectionAttempt,
|
cancelConnectionAttempt,
|
||||||
closeDatabaseConnection,
|
closeDatabaseConnection,
|
||||||
isPinned,
|
isPinned,
|
||||||
@@ -4433,6 +4435,15 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
|
|||||||
items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X });
|
items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X });
|
||||||
} else if (canDisconnectConnection()) {
|
} else if (canDisconnectConnection()) {
|
||||||
items.push({ label: connectionDisconnectMenuLabel(), action: disconnectConnection, icon: Unplug });
|
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) {
|
} else if (!isConnected.value) {
|
||||||
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
|
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,26 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
|||||||
return connectionDisconnectTargets().some((target) => connectionStore.connectedIds.has(target.connectionId));
|
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() {
|
async function cancelConnectionAttempt() {
|
||||||
const connectionId = activeNode.value.connectionId;
|
const connectionId = activeNode.value.connectionId;
|
||||||
if (!connectionId) return;
|
if (!connectionId) return;
|
||||||
@@ -366,6 +386,8 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
|||||||
disconnectConnection,
|
disconnectConnection,
|
||||||
connectionDisconnectMenuLabel,
|
connectionDisconnectMenuLabel,
|
||||||
canDisconnectConnection,
|
canDisconnectConnection,
|
||||||
|
canForgetSessionCredential,
|
||||||
|
disconnectAndForgetConnectionPassword,
|
||||||
cancelConnectionAttempt,
|
cancelConnectionAttempt,
|
||||||
closeDatabaseConnection,
|
closeDatabaseConnection,
|
||||||
isPinned,
|
isPinned,
|
||||||
|
|||||||
@@ -444,7 +444,9 @@ export default {
|
|||||||
user: "User",
|
user: "User",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
savePassword: "Save 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",
|
promptPasswordTitle: "Enter password",
|
||||||
promptPasswordMessage: "Enter the password for connection “{connection}”.",
|
promptPasswordMessage: "Enter the password for connection “{connection}”.",
|
||||||
promptPasswordPlaceholder: "Password",
|
promptPasswordPlaceholder: "Password",
|
||||||
|
|||||||
@@ -445,7 +445,9 @@ export default withEnglishFallback({
|
|||||||
user: "Usuario",
|
user: "Usuario",
|
||||||
password: "Contraseña",
|
password: "Contraseña",
|
||||||
savePassword: "Guardar 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",
|
promptPasswordTitle: "Introducir contraseña",
|
||||||
promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”.",
|
promptPasswordMessage: "Introduzca la contraseña de la conexión “{connection}”.",
|
||||||
promptPasswordPlaceholder: "Contraseña",
|
promptPasswordPlaceholder: "Contraseña",
|
||||||
|
|||||||
@@ -444,7 +444,9 @@ export default withEnglishFallback({
|
|||||||
user: "Utente",
|
user: "Utente",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
savePassword: "Salva 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",
|
promptPasswordTitle: "Inserisci password",
|
||||||
promptPasswordMessage: "Inserisci la password per la connessione “{connection}”.",
|
promptPasswordMessage: "Inserisci la password per la connessione “{connection}”.",
|
||||||
promptPasswordPlaceholder: "Password",
|
promptPasswordPlaceholder: "Password",
|
||||||
|
|||||||
@@ -444,7 +444,9 @@ export default withEnglishFallback({
|
|||||||
user: "ユーザー",
|
user: "ユーザー",
|
||||||
password: "パスワード",
|
password: "パスワード",
|
||||||
savePassword: "パスワードを保存",
|
savePassword: "パスワードを保存",
|
||||||
savePasswordHint: "チェックを外すと、接続のたびに入力が必要です",
|
savePasswordHint: "ローカルには保存しません。初回接続後、パスワードはこの実行中のみ保持され、アプリ終了時に消去されます。",
|
||||||
|
disconnectAndForgetPassword: "切断してこのパスワードを忘れる",
|
||||||
|
passwordForgotten: "パスワードを忘れました。次回の接続で再入力が必要です。",
|
||||||
promptPasswordTitle: "パスワードを入力",
|
promptPasswordTitle: "パスワードを入力",
|
||||||
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。",
|
promptPasswordMessage: "接続「{connection}」のパスワードを入力してください。",
|
||||||
promptPasswordPlaceholder: "パスワード",
|
promptPasswordPlaceholder: "パスワード",
|
||||||
|
|||||||
@@ -438,7 +438,9 @@ export default withEnglishFallback({
|
|||||||
user: "사용자",
|
user: "사용자",
|
||||||
password: "비밀번호",
|
password: "비밀번호",
|
||||||
savePassword: "비밀번호 저장",
|
savePassword: "비밀번호 저장",
|
||||||
savePasswordHint: "체크 해제 시 연결할 때마다 입력해야 합니다",
|
savePasswordHint: "로컬에 저장하지 않습니다. 첫 연결 후 비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.",
|
||||||
|
disconnectAndForgetPassword: "연결 끊고 비밀번호 잊기",
|
||||||
|
passwordForgotten: "비밀번호를 잊었습니다. 다음 연결 시 다시 입력해야 합니다.",
|
||||||
promptPasswordTitle: "비밀번호 입력",
|
promptPasswordTitle: "비밀번호 입력",
|
||||||
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.",
|
promptPasswordMessage: "연결 “{connection}”의 비밀번호를 입력하세요.",
|
||||||
promptPasswordPlaceholder: "비밀번호",
|
promptPasswordPlaceholder: "비밀번호",
|
||||||
|
|||||||
@@ -445,7 +445,9 @@ export default withEnglishFallback({
|
|||||||
user: "Usuário",
|
user: "Usuário",
|
||||||
password: "Senha",
|
password: "Senha",
|
||||||
savePassword: "Salvar 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",
|
promptPasswordTitle: "Digite a senha",
|
||||||
promptPasswordMessage: "Digite a senha da conexão “{connection}”.",
|
promptPasswordMessage: "Digite a senha da conexão “{connection}”.",
|
||||||
promptPasswordPlaceholder: "Senha",
|
promptPasswordPlaceholder: "Senha",
|
||||||
|
|||||||
@@ -369,7 +369,9 @@ export default withEnglishFallback({
|
|||||||
user: "用户名",
|
user: "用户名",
|
||||||
password: "密码",
|
password: "密码",
|
||||||
savePassword: "本地保存密码",
|
savePassword: "本地保存密码",
|
||||||
savePasswordHint: "取消勾选后,每次连接都需要输入密码",
|
savePasswordHint: "不保存到本地。首次连接后,密码仅在本次运行期间保留;退出应用后将清除。",
|
||||||
|
disconnectAndForgetPassword: "断开并忘记本次密码",
|
||||||
|
passwordForgotten: "已忘记密码,下次连接需重新输入。",
|
||||||
promptPasswordTitle: "输入密码",
|
promptPasswordTitle: "输入密码",
|
||||||
promptPasswordMessage: "请输入连接“{connection}”的密码。",
|
promptPasswordMessage: "请输入连接“{connection}”的密码。",
|
||||||
promptPasswordPlaceholder: "密码",
|
promptPasswordPlaceholder: "密码",
|
||||||
|
|||||||
@@ -445,7 +445,9 @@ export default withEnglishFallback({
|
|||||||
user: "使用者名稱",
|
user: "使用者名稱",
|
||||||
password: "密碼",
|
password: "密碼",
|
||||||
savePassword: "本機儲存密碼",
|
savePassword: "本機儲存密碼",
|
||||||
savePasswordHint: "取消勾選後,每次連線都需要輸入密碼",
|
savePasswordHint: "不儲存到本機。首次連線後,密碼僅在本次執行期間保留;退出應用程式後將清除。",
|
||||||
|
disconnectAndForgetPassword: "中斷連線並忘記本次密碼",
|
||||||
|
passwordForgotten: "已忘記密碼,下次連線需重新輸入。",
|
||||||
promptPasswordTitle: "輸入密碼",
|
promptPasswordTitle: "輸入密碼",
|
||||||
promptPasswordMessage: "請輸入連線「{connection}」的密碼。",
|
promptPasswordMessage: "請輸入連線「{connection}」的密碼。",
|
||||||
promptPasswordPlaceholder: "密碼",
|
promptPasswordPlaceholder: "密碼",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
|
|||||||
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
|
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
|
||||||
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
|
export const connectionFinalProxyPort = forward("connectionFinalProxyPort");
|
||||||
export const disconnectDb = forward("disconnectDb");
|
export const disconnectDb = forward("disconnectDb");
|
||||||
|
export const sessionCredentialStatus = forward("sessionCredentialStatus");
|
||||||
|
export const forgetSessionCredential = forward("forgetSessionCredential");
|
||||||
export const checkConnectionHealth = forward("checkConnectionHealth");
|
export const checkConnectionHealth = forward("checkConnectionHealth");
|
||||||
export const connectionIdentifierQuote = forward("connectionIdentifierQuote");
|
export const connectionIdentifierQuote = forward("connectionIdentifierQuote");
|
||||||
export const closeDatabaseConnection = forward("closeDatabaseConnection");
|
export const closeDatabaseConnection = forward("closeDatabaseConnection");
|
||||||
|
|||||||
@@ -373,6 +373,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number)
|
|||||||
return post("/api/connection/disconnect", { connectionId, clientAttempt });
|
return post("/api/connection/disconnect", { connectionId, clientAttempt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sessionCredentialStatus(connectionId: string): Promise<boolean> {
|
||||||
|
return post("/api/connection/session-credential-status", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgetSessionCredential(connectionId: string): Promise<void> {
|
||||||
|
return post("/api/connection/forget-session-credential", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkConnectionHealth(connectionId: string): Promise<void> {
|
export async function checkConnectionHealth(connectionId: string): Promise<void> {
|
||||||
return post("/api/connection/check-health", { connectionId });
|
return post("/api/connection/check-health", { connectionId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -924,6 +924,14 @@ export async function disconnectDb(connectionId: string, clientAttempt?: number)
|
|||||||
return invokeBackend("disconnect_db", { connectionId, clientAttempt });
|
return invokeBackend("disconnect_db", { connectionId, clientAttempt });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sessionCredentialStatus(connectionId: string): Promise<boolean> {
|
||||||
|
return invokeBackend("session_credential_status", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgetSessionCredential(connectionId: string): Promise<void> {
|
||||||
|
return invokeBackend("forget_session_credential", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkConnectionHealth(connectionId: string): Promise<void> {
|
export async function checkConnectionHealth(connectionId: string): Promise<void> {
|
||||||
return invokeBackend("check_connection_health", { connectionId });
|
return invokeBackend("check_connection_health", { connectionId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ function installApiMocks(extra: Record<string, unknown> = {}) {
|
|||||||
vi.doMock("@/lib/backend/api", () => ({
|
vi.doMock("@/lib/backend/api", () => ({
|
||||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||||
connectDb: vi.fn().mockResolvedValue("pg-1"),
|
connectDb: vi.fn().mockResolvedValue("pg-1"),
|
||||||
|
disconnectDb: vi.fn().mockResolvedValue(undefined),
|
||||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||||
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
|
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
|
||||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
|
||||||
|
forgetSessionCredential: vi.fn().mockResolvedValue(undefined),
|
||||||
...extra,
|
...extra,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,58 @@ describe("connectionStore save_password opt-out", () => {
|
|||||||
expect(requestPassword).not.toHaveBeenCalled();
|
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 () => {
|
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");
|
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 });
|
installApiMocks({ connectDb });
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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
|
* Prompt for a transient password when saving is disabled, or when a server
|
||||||
* confirms that a metadata-only synced connection sent no password. The
|
* confirms that a metadata-only synced connection sent no password. The
|
||||||
@@ -3109,9 +3126,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
try {
|
try {
|
||||||
let rememberPassword = false;
|
let rememberPassword = false;
|
||||||
if (config.save_password === false && !config.password) {
|
if (config.save_password === false && !config.password) {
|
||||||
const prompted = await ensureConnectionPassword(config);
|
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
|
||||||
config = prompted.config;
|
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
|
||||||
rememberPassword = prompted.rememberPassword;
|
if (!(await hasSessionCredential(config.id))) {
|
||||||
|
const prompted = await ensureConnectionPassword(config);
|
||||||
|
config = prompted.config;
|
||||||
|
rememberPassword = prompted.rememberPassword;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await beforeConnectHandler?.(config);
|
await beforeConnectHandler?.(config);
|
||||||
if (config.db_type === "sqlserver") {
|
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) {
|
async function closeDatabaseConnection(connectionId: string, database: string) {
|
||||||
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
|
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
|
||||||
await api.closeDatabaseConnection(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
|
// in-flight dedup above keeps its exact microtask cadence; only await the
|
||||||
// interactive prompt when the connection actually needs a typed password.
|
// interactive prompt when the connection actually needs a typed password.
|
||||||
if (config.save_password === false && !config.password) {
|
if (config.save_password === false && !config.password) {
|
||||||
const prompted = await ensureConnectionPassword(config);
|
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
|
||||||
config = prompted.config;
|
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
|
||||||
rememberPassword = prompted.rememberPassword;
|
if (!(await hasSessionCredential(connectionId))) {
|
||||||
|
const prompted = await ensureConnectionPassword(config);
|
||||||
|
config = prompted.config;
|
||||||
|
rememberPassword = prompted.rememberPassword;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await beforeConnectHandler?.(config);
|
await beforeConnectHandler?.(config);
|
||||||
if (config.db_type === "sqlserver") {
|
if (config.db_type === "sqlserver") {
|
||||||
@@ -7816,6 +7851,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
connect,
|
connect,
|
||||||
cancelConnecting,
|
cancelConnecting,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
disconnectAndForgetConnectionPassword,
|
||||||
|
hasSessionCredential,
|
||||||
closeDatabaseConnection,
|
closeDatabaseConnection,
|
||||||
ensureConnected,
|
ensureConnected,
|
||||||
loadConnectedConnectionRootForSidebarSearch,
|
loadConnectedConnectionRootForSidebarSearch,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use crate::models::connection::{
|
|||||||
use crate::path_utils::expand_tilde;
|
use crate::path_utils::expand_tilde;
|
||||||
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
|
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
|
||||||
use crate::query_cancel::RunningQueries;
|
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::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORKER_MAX_PROCESSES_DEFAULT};
|
||||||
use crate::task_supervisor::TaskSupervisor;
|
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.
|
/// Used to reconstruct a TLS connector compatible with the original connection when cancelling.
|
||||||
postgres_cancel_contexts: Arc<RwLock<HashMap<String, db::postgres::PostgresCancelContext>>>,
|
postgres_cancel_contexts: Arc<RwLock<HashMap<String, db::postgres::PostgresCancelContext>>>,
|
||||||
pub transaction_sessions: Arc<RwLock<HashMap<String, TransactionSession>>>,
|
pub transaction_sessions: Arc<RwLock<HashMap<String, TransactionSession>>>,
|
||||||
|
/// `save_password=false` 连接本次运行期的临时密码(内存,进程退出即丢,
|
||||||
|
/// 绝不落盘)。键为 `(owner_scope, connection_id)`:桌面端 owner 为空串,
|
||||||
|
/// Web 端 owner 为已认证会话 token,不同登录会话互不可见。建池/池重建/
|
||||||
|
/// AI/元数据从它读取,前端通过状态接口查询。
|
||||||
|
pub session_credentials: SessionCredentialStore,
|
||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
pub mq_registry: crate::mq::MqAdminRegistry,
|
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(
|
async fn handle_shared_connection_open_error(
|
||||||
&self,
|
&self,
|
||||||
error: crate::agent_runtime::SharedConnectionOpenError,
|
error: crate::agent_runtime::SharedConnectionOpenError,
|
||||||
@@ -1041,6 +1084,7 @@ impl AppState {
|
|||||||
duckdb_worker_max_processes: AtomicUsize::new(DUCKDB_WORKER_MAX_PROCESSES_DEFAULT),
|
duckdb_worker_max_processes: AtomicUsize::new(DUCKDB_WORKER_MAX_PROCESSES_DEFAULT),
|
||||||
postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())),
|
postgres_cancel_contexts: Arc::new(RwLock::new(HashMap::new())),
|
||||||
transaction_sessions: Arc::new(RwLock::new(HashMap::new())),
|
transaction_sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
session_credentials: SessionCredentialStore::new(),
|
||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
mq_registry: crate::mq::MqAdminRegistry::new(),
|
mq_registry: crate::mq::MqAdminRegistry::new(),
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1389,14 @@ impl AppState {
|
|||||||
routing.detach_pool_by_key(&pool_key, true).await;
|
routing.detach_pool_by_key(&pool_key, true).await;
|
||||||
return Err("Agent runtime is unavailable while publishing the connection pool".to_string());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1735,6 +1787,11 @@ impl AppState {
|
|||||||
drop(conns);
|
drop(conns);
|
||||||
if self.remove_pool_if_duckdb_isolation_mismatch(&pool_key).await {
|
if self.remove_pool_if_duckdb_isolation_mismatch(&pool_key).await {
|
||||||
// Recreate below using the current DuckDB isolation mode.
|
// 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 {
|
} else if !validate_existing_pool || !self.remove_stale_connection_pool(&pool_key).await {
|
||||||
self.touch_pool_activity(&pool_key).await;
|
self.touch_pool_activity(&pool_key).await;
|
||||||
return Ok(pool_key);
|
return Ok(pool_key);
|
||||||
@@ -1752,7 +1809,8 @@ impl AppState {
|
|||||||
break;
|
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)?;
|
validate_h2_file_connection(&db_config)?;
|
||||||
self.ensure_current_connection_attempt(connection_id, connection_attempt).await?;
|
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::<Vec<_>>();
|
let pool_keys = self.connections.read().await.keys().cloned().collect::<Vec<_>>();
|
||||||
self.stop_keepalive_tasks(&pool_keys).await;
|
self.stop_keepalive_tasks(&pool_keys).await;
|
||||||
self.pool_activity.write().await.clear();
|
self.pool_activity.write().await.clear();
|
||||||
|
self.session_credentials.clear_pool_owners();
|
||||||
self.postgres_cancel_contexts.write().await.clear();
|
self.postgres_cancel_contexts.write().await.clear();
|
||||||
self.draining_pools.lock().unwrap_or_else(|error| error.into_inner()).clear();
|
self.draining_pools.lock().unwrap_or_else(|error| error.into_inner()).clear();
|
||||||
self.connections.write().await.drain().collect()
|
self.connections.write().await.drain().collect()
|
||||||
@@ -4230,6 +4289,7 @@ impl AppState {
|
|||||||
cancel_contexts.remove(key);
|
cancel_contexts.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.session_credentials.remove_pool_owners(&keys_to_remove);
|
||||||
let mut conns = self.connections.write().await;
|
let mut conns = self.connections.write().await;
|
||||||
let mut removed = Vec::with_capacity(keys_to_remove.len());
|
let mut removed = Vec::with_capacity(keys_to_remove.len());
|
||||||
for key in keys_to_remove {
|
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)
|
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(
|
fn pool_key_for_session_role(
|
||||||
config: Option<&ConnectionConfig>,
|
config: Option<&ConnectionConfig>,
|
||||||
base_pool_key: String,
|
base_pool_key: String,
|
||||||
@@ -5161,15 +5241,16 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
agent_connect_timeout, connection_probe_endpoints, connection_remote_endpoint, connection_url_for_endpoint,
|
agent_connect_timeout, connection_configs_pool_equivalent, connection_probe_endpoints,
|
||||||
database_connection_config, database_connection_config_with_catalog,
|
connection_remote_endpoint, connection_url_for_endpoint, database_connection_config,
|
||||||
gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver,
|
database_connection_config_with_catalog, gaussdb_identifier_quote_from_query_result,
|
||||||
kafka_single_loopback_bootstrap_endpoint, metadata_connection_config, mysql_metadata_fallback_url,
|
gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, kafka_single_loopback_bootstrap_endpoint,
|
||||||
mysql_pool_setup_queries, oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries,
|
metadata_connection_config, mysql_metadata_fallback_url, mysql_pool_setup_queries,
|
||||||
prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id,
|
oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint,
|
||||||
redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, sqlserver_legacy_driver_error,
|
redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix,
|
||||||
sqlserver_uses_legacy_driver, task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool,
|
sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver,
|
||||||
uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind,
|
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,
|
GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS,
|
||||||
};
|
};
|
||||||
use crate::agent_connection::{
|
use crate::agent_connection::{
|
||||||
@@ -5280,6 +5361,172 @@ mod tests {
|
|||||||
assert_eq!(metadata_connection_config(&config).database, None);
|
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]
|
#[test]
|
||||||
fn task_client_session_ids_are_stable_and_isolated() {
|
fn task_client_session_ids_are_stable_and_isolated() {
|
||||||
assert_eq!(task_client_session_id("table-export", "job-1"), "table-export:job-1");
|
assert_eq!(task_client_session_id("table-export", "job-1"), "table-export:job-1");
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub mod saved_sql;
|
|||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod schema_diff;
|
pub mod schema_diff;
|
||||||
pub mod script_generator;
|
pub mod script_generator;
|
||||||
|
pub mod session_credentials;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
pub mod sql_analysis;
|
pub mod sql_analysis;
|
||||||
pub mod sql_diagnostics;
|
pub mod sql_diagnostics;
|
||||||
|
|||||||
@@ -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<CredentialKey, SessionCredential>,
|
||||||
|
pool_credential_owners: HashMap<String, String>,
|
||||||
|
next_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SessionCredentialWriteToken {
|
||||||
|
key: CredentialKey,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前请求的认证会话作用域(Web 会话 token / 桌面端空串)。
|
||||||
|
///
|
||||||
|
/// 由 Web 鉴权中间件在请求边界通过 [`with_credential_owner`] 注入;未注入(桌面端、
|
||||||
|
/// 后台任务、中间件未覆盖的路径)返回 `None`,调用方按空 owner 处理。
|
||||||
|
pub fn current_credential_owner() -> Option<String> {
|
||||||
|
CREDENTIAL_OWNER.try_get().ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在一个 future 的整个执行期间设置凭据 owner 作用域。
|
||||||
|
///
|
||||||
|
/// Web 鉴权中间件用它包裹下游处理器,使请求处理任务内(含其 await 到的池创建)
|
||||||
|
/// 都能读到当前会话 owner。未被此函数包裹的调用(桌面端、独立后台任务)等价于
|
||||||
|
/// 空 owner。
|
||||||
|
pub async fn with_credential_owner<F, T>(owner_scope: Option<String>, future: F) -> T
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
{
|
||||||
|
CREDENTIAL_OWNER.scope(owner_scope, future).await
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
static CREDENTIAL_OWNER: Option<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内存会话凭据仓库:`(owner_scope, connection_id) -> password`。
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SessionCredentialStore {
|
||||||
|
state: RwLock<SessionCredentialState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SessionCredentialWriteToken> {
|
||||||
|
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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,8 @@ pub async fn change_password(
|
|||||||
pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Response {
|
pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Response {
|
||||||
if let Some(token) = extract_session_token(&req) {
|
if let Some(token) = extract_session_token(&req) {
|
||||||
state.sessions.write().await.remove(&token);
|
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));
|
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()
|
(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
|
// Check session token
|
||||||
if let Some(token) = extract_session_token(&req) {
|
let token = extract_session_token(&req);
|
||||||
if state.sessions.read().await.contains(&token) {
|
if let Some(ref token) = token {
|
||||||
return next.run(req).await;
|
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/final-proxy-port", post(routes::connection::connection_final_proxy_port))
|
||||||
.route("/connection/disconnect", post(routes::connection::disconnect_db))
|
.route("/connection/disconnect", post(routes::connection::disconnect_db))
|
||||||
.route("/connection/check-health", post(routes::connection::check_connection_health))
|
.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/identifier-quote", post(routes::connection::connection_identifier_quote))
|
||||||
.route("/connection/close-database", post(routes::connection::close_database_connection))
|
.route("/connection/close-database", post(routes::connection::close_database_connection))
|
||||||
.route("/connection/save", post(routes::connection::save_connections))
|
.route("/connection/save", post(routes::connection::save_connections))
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ use std::collections::HashSet;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum::Json;
|
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 dbx_core::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::auth::session_token_from_headers;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::state::WebState;
|
use crate::state::WebState;
|
||||||
|
|
||||||
@@ -34,6 +36,12 @@ pub struct CloseDatabaseConnectionRequest {
|
|||||||
pub database: String,
|
pub database: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionCredentialStatusRequest {
|
||||||
|
pub connection_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ConnectionIdentifierQuoteRequest {
|
pub struct ConnectionIdentifierQuoteRequest {
|
||||||
@@ -253,6 +261,7 @@ pub async fn test_connection_with_info(
|
|||||||
|
|
||||||
pub async fn connect_db(
|
pub async fn connect_db(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(body): Json<ConnectRequest>,
|
Json(body): Json<ConnectRequest>,
|
||||||
) -> Result<Json<String>, AppError> {
|
) -> Result<Json<String>, AppError> {
|
||||||
let config = body.config;
|
let config = body.config;
|
||||||
@@ -266,16 +275,44 @@ pub async fn connect_db(
|
|||||||
}
|
}
|
||||||
let app = &state.app;
|
let app = &state.app;
|
||||||
let connection_id = config.id.clone();
|
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;
|
let attempt = app.begin_connection_attempt_with_client_attempt(&connection_id, body.client_attempt).await;
|
||||||
|
|
||||||
app.remove_connection_pools_detached(&connection_id).await;
|
// save_password=false 连接:
|
||||||
app.reset_connection_transport_for_config(&connection_id, &config).await;
|
// 1) 先把本次输入的密码按 owner(登录会话)记入内存会话凭据仓库,供池创建/
|
||||||
app.configs.write().await.insert(connection_id.clone(), config.clone());
|
// 池重建按 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 {
|
if let Err(error) = sync_mongo_legacy_driver_fallback(&state, &config).await {
|
||||||
app.remove_connection_pools_detached(&connection_id).await;
|
app.remove_connection_pools_detached(&connection_id).await;
|
||||||
app.reset_connection_transport_for_config(&connection_id, &config).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);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +345,7 @@ pub async fn save_connection_database_info(
|
|||||||
|
|
||||||
pub async fn connection_final_proxy_port(
|
pub async fn connection_final_proxy_port(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(body): Json<ConnectRequest>,
|
Json(body): Json<ConnectRequest>,
|
||||||
) -> Result<Json<u16>, AppError> {
|
) -> Result<Json<u16>, AppError> {
|
||||||
let runtime_config = body.config.canonicalized();
|
let runtime_config = body.config.canonicalized();
|
||||||
@@ -326,9 +364,19 @@ pub async fn connection_final_proxy_port(
|
|||||||
let app = &state.app;
|
let app = &state.app;
|
||||||
let connection_id = runtime_config.id.clone();
|
let connection_id = runtime_config.id.clone();
|
||||||
let db_config = dbx_core::connection::metadata_connection_config(&runtime_config);
|
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)?;
|
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))
|
Ok(Json(port))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +416,36 @@ pub async fn check_connection_health(
|
|||||||
Ok(Json(()))
|
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(
|
pub async fn connection_identifier_quote(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Json(body): Json<ConnectionIdentifierQuoteRequest>,
|
Json(body): Json<ConnectionIdentifierQuoteRequest>,
|
||||||
@@ -391,6 +469,7 @@ pub async fn close_database_connection(
|
|||||||
|
|
||||||
pub async fn save_connections(
|
pub async fn save_connections(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
|
_headers: HeaderMap,
|
||||||
Json(body): Json<SaveConnectionsRequest>,
|
Json(body): Json<SaveConnectionsRequest>,
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
for config in &body.configs {
|
for config in &body.configs {
|
||||||
@@ -416,6 +495,8 @@ pub async fn mcp_add_connection(
|
|||||||
Json(body): Json<McpAddConnectionRequest>,
|
Json(body): Json<McpAddConnectionRequest>,
|
||||||
) -> Result<Json<ConnectionConfig>, AppError> {
|
) -> Result<Json<ConnectionConfig>, AppError> {
|
||||||
let saved = state.app.storage.add_connection_for_mcp(body.config).await.map_err(AppError::from)?;
|
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());
|
state.app.configs.write().await.insert(saved.id.clone(), saved.clone());
|
||||||
Ok(Json(saved))
|
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)
|
.duplicate_connection_for_mcp(&body.source_id, &body.copy_id, &body.copy_name)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.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());
|
state.app.configs.write().await.insert(saved.id.clone(), saved.clone());
|
||||||
Ok(Json(saved))
|
Ok(Json(saved))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mcp_remove_connection(
|
pub async fn mcp_remove_connection(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
|
_headers: HeaderMap,
|
||||||
Json(body): Json<McpRemoveConnectionRequest>,
|
Json(body): Json<McpRemoveConnectionRequest>,
|
||||||
) -> Result<Json<bool>, AppError> {
|
) -> Result<Json<bool>, AppError> {
|
||||||
let connection_id = body.connection_id;
|
let connection_id = body.connection_id;
|
||||||
let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError::from)?;
|
let removed = state.app.storage.remove_connection_for_mcp(&connection_id).await.map_err(AppError::from)?;
|
||||||
if removed {
|
if removed {
|
||||||
state.app.configs.write().await.remove(&connection_id);
|
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.remove_connection_pools_detached(&connection_id).await;
|
||||||
state.app.nacos_registry.drop_connection(&connection_id).await;
|
state.app.nacos_registry.drop_connection(&connection_id).await;
|
||||||
#[cfg(feature = "mq-admin")]
|
#[cfg(feature = "mq-admin")]
|
||||||
@@ -450,7 +535,10 @@ pub async fn mcp_remove_connection(
|
|||||||
Ok(Json(removed))
|
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 configs = state.app.storage.load_connections().await.map_err(AppError::from)?;
|
||||||
let sync = sync_connection_configs(&state, &configs).await;
|
let sync = sync_connection_configs(&state, &configs).await;
|
||||||
remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).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
|
true
|
||||||
} else {
|
} else {
|
||||||
connection_pool_ids_to_drop.insert(id.clone());
|
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 {
|
if existing.db_type == dbx_core::models::connection::DatabaseType::Nacos {
|
||||||
nacos_adapter_ids_to_drop.insert(id.clone());
|
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 {
|
if previous.db_type == dbx_core::models::connection::DatabaseType::MessageQueue {
|
||||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
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());
|
connection_pool_ids_to_drop.insert(config.id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,13 +637,14 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
apply_mongo_legacy_driver_profile, connect_db, connection_final_proxy_port, disconnect_db, load_connections,
|
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,
|
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,
|
run_temporary_connection_test, save_connection_database_info, save_connections, sync_connection_configs,
|
||||||
test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
|
test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
|
||||||
McpDuplicateConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
|
McpDuplicateConnectionRequest, McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest,
|
||||||
SaveConnectionsRequest,
|
SaveConnectionsRequest,
|
||||||
};
|
};
|
||||||
use crate::state::WebState;
|
use crate::state::WebState;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use dbx_core::connection::{AppState, PoolKind};
|
use dbx_core::connection::{AppState, PoolKind};
|
||||||
use dbx_core::models::connection::{
|
use dbx_core::models::connection::{
|
||||||
@@ -838,10 +934,13 @@ mod tests {
|
|||||||
name: "analytics".to_string(),
|
name: "analytics".to_string(),
|
||||||
path: dir.join("analytics.sqlite").to_string_lossy().to_string(),
|
path: dir.join("analytics.sqlite").to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
let connect_error =
|
let connect_error = connect_db(
|
||||||
connect_db(State(state.clone()), Json(ConnectRequest { config: invalid.clone(), client_attempt: None }))
|
State(state.clone()),
|
||||||
.await
|
HeaderMap::new(),
|
||||||
.unwrap_err();
|
Json(ConnectRequest { config: invalid.clone(), client_attempt: None }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
assert!(connect_error.message.contains("in-memory main database"), "{}", connect_error.message);
|
assert!(connect_error.message.contains("in-memory main database"), "{}", connect_error.message);
|
||||||
|
|
||||||
invalid.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
invalid.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig {
|
||||||
@@ -858,6 +957,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
let proxy_error = connection_final_proxy_port(
|
let proxy_error = connection_final_proxy_port(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
Json(ConnectRequest { config: invalid, client_attempt: None }),
|
Json(ConnectRequest { config: invalid, client_attempt: None }),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -909,9 +1009,12 @@ mod tests {
|
|||||||
let db_path = dir.join("app.db");
|
let db_path = dir.join("app.db");
|
||||||
let config = sqlite_config("sqlite-conn", &db_path.to_string_lossy());
|
let config = sqlite_config("sqlite-conn", &db_path.to_string_lossy());
|
||||||
|
|
||||||
let result =
|
let result = save_connections(
|
||||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![config.clone()] }))
|
State(state.clone()),
|
||||||
.await;
|
HeaderMap::new(),
|
||||||
|
Json(SaveConnectionsRequest { configs: vec![config.clone()] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let configs = state.app.configs.read().await;
|
let configs = state.app.configs.read().await;
|
||||||
@@ -1010,6 +1113,7 @@ mod tests {
|
|||||||
|
|
||||||
let removed_result = mcp_remove_connection(
|
let removed_result = mcp_remove_connection(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
Json(McpRemoveConnectionRequest { connection_id: removed.id.clone() }),
|
Json(McpRemoveConnectionRequest { connection_id: removed.id.clone() }),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1019,6 +1123,7 @@ mod tests {
|
|||||||
|
|
||||||
let scope_error = mcp_remove_connection(
|
let scope_error = mcp_remove_connection(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
Json(McpRemoveConnectionRequest { connection_id: kept.id.clone() }),
|
Json(McpRemoveConnectionRequest { connection_id: kept.id.clone() }),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1086,9 +1191,12 @@ mod tests {
|
|||||||
let first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
|
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 updated = mq_config("mq-conn", "http://127.0.0.1:8081");
|
||||||
let result =
|
let result = save_connections(
|
||||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![updated.clone()] }))
|
State(state.clone()),
|
||||||
.await;
|
HeaderMap::new(),
|
||||||
|
Json(SaveConnectionsRequest { configs: vec![updated.clone()] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let cached_admin_url = state
|
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 first = state.app.mq_registry.get_or_build(&initial).await.unwrap().adapter;
|
||||||
|
|
||||||
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
|
let updated = mq_config("mq-conn", &spawn_pulsar_clusters_server().await);
|
||||||
let result =
|
let result = connect_db(
|
||||||
connect_db(State(state.clone()), Json(ConnectRequest { config: updated.clone(), client_attempt: None }))
|
State(state.clone()),
|
||||||
.await;
|
HeaderMap::new(),
|
||||||
|
Json(ConnectRequest { config: updated.clone(), client_attempt: None }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let second = state.app.mq_registry.get_or_build(&updated).await.unwrap().adapter;
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn load_connections_drops_cached_pool_for_updated_config() {
|
async fn load_connections_drops_cached_pool_for_updated_config() {
|
||||||
let (state, dir) = test_web_state().await;
|
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.configs.write().await.insert(initial.id.clone(), initial.clone());
|
||||||
state.app.connections.write().await.insert(initial.id.clone(), PoolKind::MessageQueue);
|
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());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let configs = state.app.configs.read().await;
|
let configs = state.app.configs.read().await;
|
||||||
@@ -1156,6 +1395,94 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
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")]
|
#[cfg(feature = "mq-admin")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn save_connections_removes_deleted_runtime_config_and_mq_adapter() {
|
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 stale = state.app.mq_registry.get_or_build(&removed).await.unwrap().adapter;
|
||||||
|
|
||||||
let result =
|
let result = save_connections(
|
||||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
State(state.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(SaveConnectionsRequest { configs: vec![kept.clone()] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let configs = state.app.configs.read().await;
|
let configs = state.app.configs.read().await;
|
||||||
@@ -1197,8 +1528,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
state.app.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
|
state.app.connections.write().await.insert(removed.id.clone(), PoolKind::MessageQueue);
|
||||||
|
|
||||||
let result =
|
let result = save_connections(
|
||||||
save_connections(State(state.clone()), Json(SaveConnectionsRequest { configs: vec![kept.clone()] })).await;
|
State(state.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(SaveConnectionsRequest { configs: vec![kept.clone()] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
assert!(!state.app.connections.read().await.contains_key(&removed.id));
|
assert!(!state.app.connections.read().await.contains_key(&removed.id));
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ pub use dbx_core::agent_connection::{
|
|||||||
oracle_error_with_driver_hint, should_retry_mongo_with_legacy_driver,
|
oracle_error_with_driver_hint, should_retry_mongo_with_legacy_driver,
|
||||||
};
|
};
|
||||||
pub use dbx_core::connection::{
|
pub use dbx_core::connection::{
|
||||||
agent_connect_timeout, connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint,
|
agent_connect_timeout, connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_configs_pool_equivalent,
|
||||||
gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, metadata_connection_config,
|
connection_url_for_endpoint, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver,
|
||||||
prestosql_jdbc_config_for_endpoint, probe_connection_endpoint, redacted_connection_url_for_endpoint, AppState,
|
metadata_connection_config, prestosql_jdbc_config_for_endpoint, probe_connection_endpoint,
|
||||||
MysqlMode, PoolKind,
|
redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind,
|
||||||
};
|
};
|
||||||
use dbx_core::database_capabilities;
|
use dbx_core::database_capabilities;
|
||||||
use dbx_core::db;
|
use dbx_core::db;
|
||||||
@@ -170,7 +170,7 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
connect_sqlite_from_config, gaussdb_m_jdbc_command_config, mark_mongo_legacy_driver,
|
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_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::connection::{AppState, PoolKind};
|
||||||
use dbx_core::models::connection::{AttachedDatabaseConfig, ConnectionConfig, DatabaseType};
|
use dbx_core::models::connection::{AttachedDatabaseConfig, ConnectionConfig, DatabaseType};
|
||||||
@@ -593,6 +593,39 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
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]
|
#[tauri::command]
|
||||||
@@ -636,6 +669,8 @@ async fn sync_connection_configs(state: &AppState, configs: &[ConnectionConfig])
|
|||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
connection_pool_ids_to_drop.insert(id.clone());
|
connection_pool_ids_to_drop.insert(id.clone());
|
||||||
|
// 连接已被删除:同步清理本次运行期会话凭据。
|
||||||
|
state.session_credentials.clear_connection(id);
|
||||||
if existing.db_type == DatabaseType::Nacos {
|
if existing.db_type == DatabaseType::Nacos {
|
||||||
nacos_adapter_ids_to_drop.insert(id.clone());
|
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 {
|
if previous.db_type == DatabaseType::MessageQueue {
|
||||||
mq_adapter_ids_to_drop.insert(config.id.clone());
|
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());
|
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))
|
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]
|
#[tauri::command]
|
||||||
pub async fn connect_db(
|
pub async fn connect_db(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
@@ -1213,7 +1261,10 @@ pub async fn connect_db(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
let id = config.id.clone();
|
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 attempt = state.begin_connection_attempt_with_client_attempt(&id, client_attempt).await;
|
||||||
let mut connected_config = config.clone();
|
let mut connected_config = config.clone();
|
||||||
let mut connected_db_config = db_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;
|
state.reset_connection_transport_for_config(&id, &db_config).await;
|
||||||
return Err(err);
|
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);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
Err(e) => e,
|
Err(e) => e,
|
||||||
@@ -1552,7 +1608,13 @@ pub async fn connect_db(
|
|||||||
state.reset_connection_transport_for_config(&id, &connected_db_config).await;
|
state.reset_connection_transport_for_config(&id, &connected_db_config).await;
|
||||||
return Err(err);
|
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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
@@ -1576,9 +1638,16 @@ pub async fn connection_final_proxy_port(
|
|||||||
|
|
||||||
let connection_id = runtime_config.id.clone();
|
let connection_id = runtime_config.id.clone();
|
||||||
let db_config = metadata_connection_config(&runtime_config);
|
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?;
|
let (_, port) = state.connection_host_port(&connection_id, &db_config).await?;
|
||||||
|
record_session_credential(state.inner(), &runtime_config, &connection_id);
|
||||||
Ok(port)
|
Ok(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1619,6 +1688,32 @@ pub async fn close_database_connection(
|
|||||||
state.close_database_pool(&connection_id, database).await
|
state.close_database_pool(&connection_id, database).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询连接在本次运行期是否已输入并暂存密码(`save_password=false`)。
|
||||||
|
/// 供前端决定是否需要弹密码框;仅返回布尔状态,不泄露密码本身。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn session_credential_status(state: State<'_, Arc<AppState>>, connection_id: String) -> Result<bool, String> {
|
||||||
|
Ok(state.session_credentials.has("", &connection_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "断开并忘记本次密码":清除连接本次运行期的临时密码,下次连接需重新输入。
|
||||||
|
/// 只清内存会话凭据,不影响持久化配置与已保存密码。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn forget_session_credential(state: State<'_, Arc<AppState>>, 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<AppState>>) -> Result<(), String> {
|
||||||
|
state.session_credentials.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
pub async fn refresh_connections(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||||
state.refresh_connections().await;
|
state.refresh_connections().await;
|
||||||
|
|||||||
@@ -1607,6 +1607,9 @@ pub fn run() {
|
|||||||
commands::connection::connection_final_proxy_port,
|
commands::connection::connection_final_proxy_port,
|
||||||
commands::connection::disconnect_db,
|
commands::connection::disconnect_db,
|
||||||
commands::connection::close_database_connection,
|
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::refresh_connections,
|
||||||
commands::connection::check_connection_health,
|
commands::connection::check_connection_health,
|
||||||
commands::connection::connection_identifier_quote,
|
commands::connection::connection_identifier_quote,
|
||||||
|
|||||||
Reference in new issue
Block a user