diff --git a/README-CN.md b/README-CN.md index ea7fdde1..a882666c 100644 --- a/README-CN.md +++ b/README-CN.md @@ -54,14 +54,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 - **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥 - **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息 - **仪表板** - 在仪表板上一目了然地查看服务器信息 -- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。 -- **数据库加密** - 后端存储为加密的 SQLite 数据库文件 +- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。 +- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。 - **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 - **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 - **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面 - **语言** - 内置支持英语、中文、德语和葡萄牙语 - **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。 - **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。 +- **命令历史** - 自动完成并查看以前运行的 SSH 命令 +- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接 +- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。 # 计划功能 @@ -75,16 +78,16 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 - Windows(x64/ia32) - 便携版 - MSI 安装程序 - - Chocolatey 软件包管理器 + - Chocolatey 软件包管理器(即将推出) - Linux(x64/ia32) - 便携版 - AppImage - Deb - - Flatpak + - Flatpak(即将推出) - macOS(x64/ia32 on v12.0+) - - Apple App Store + - Apple App Store(即将推出) - DMG - - Homebrew + - Homebrew(即将推出) - iOS/iPadOS(v15.1+) - Apple App Store - ISO diff --git a/README.md b/README.md index 81af5534..0e9c10f0 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,17 @@ free and self-hosted alternative to Termius available for all platforms. - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys - **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server - **Dashboard** - View server information at a glance on your dashboard -- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. -- **Database Encryption** - Backend stored as encrypted SQLite database files +- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. +- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn - **Languages** - Built-in support for English, Chinese, German, and Portuguese - **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android. - **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. +- **Command History** - Auto-complete and view previously ran SSH commands +- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard +- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc. # Planned Features @@ -77,16 +80,16 @@ Supported Devices: - Windows (x64/ia32) - Portable - MSI Installer - - Chocolatey Package Manager + - Chocolatey Package Manager (coming soon) - Linux (x64/ia32) - Portable - AppImage - Deb - - Flatpak + - Flatpak (coming soon) - macOS (x64/ia32 on v12.0+) - - Apple App Store + - Apple App Store (coming soon) - DMG - - Homebrew + - Homebrew (coming soon) - iOS/iPadOS (v15.1+) - Apple App Store - ISO diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts index 3349a300..f73c7dfb 100644 --- a/src/backend/database/routes/terminal.ts +++ b/src/backend/database/routes/terminal.ts @@ -80,7 +80,10 @@ router.get( try { const result = await db - .selectDistinct({ command: commandHistory.command }) + .select({ + command: commandHistory.command, + maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, + }) .from(commandHistory) .where( and( @@ -88,10 +91,11 @@ router.get( eq(commandHistory.hostId, hostIdNum), ), ) - .orderBy(desc(commandHistory.executedAt)) + .groupBy(commandHistory.command) + .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) .limit(500); - const uniqueCommands = Array.from(new Set(result.map((r) => r.command))); + const uniqueCommands = result.map((r) => r.command); res.json(uniqueCommands); } catch (err) { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index a47fc087..473654d9 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -752,9 +752,7 @@ router.get("/oidc/callback", async (req, res) => { let user = await db .select() .from(users) - .where( - and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)), - ); + .where(eq(users.oidc_identifier, identifier)); let isFirstUser = false; if (!user || user.length === 0) { @@ -851,10 +849,15 @@ router.get("/oidc/callback", async (req, res) => { user = await db.select().from(users).where(eq(users.id, id)); } else { - await db - .update(users) - .set({ username: name }) - .where(eq(users.id, user[0].id)); + const isDualAuth = + user[0].password_hash && user[0].password_hash.trim() !== ""; + + if (!isDualAuth) { + await db + .update(users) + .set({ username: name }) + .where(eq(users.id, user[0].id)); + } user = await db.select().from(users).where(eq(users.id, user[0].id)); } @@ -1169,11 +1172,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({ error: "User not found" }); } + const hasPassword = + user[0].password_hash && user[0].password_hash.trim() !== ""; + const hasOidc = user[0].is_oidc && user[0].oidc_identifier; + const isDualAuth = hasPassword && hasOidc; + res.json({ userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc, + is_dual_auth: isDualAuth, totp_enabled: !!user[0].totp_enabled, }); } catch (err) { diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index e571e5db..593fd08b 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -169,6 +169,39 @@ class UserCrypto { sessionDurationMs: number, ): Promise { try { + const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId); + + if (oidcEncryptedDEK) { + const systemKey = this.deriveOIDCSystemKey(userId); + const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey); + systemKey.fill(0); + + if (!DEK || DEK.length === 0) { + databaseLogger.error( + "Failed to decrypt OIDC DEK for dual-auth user", + { + operation: "oidc_auth_dual_decrypt_failed", + userId, + }, + ); + return false; + } + + const now = Date.now(); + const oldSession = this.userSessions.get(userId); + if (oldSession) { + oldSession.dataKey.fill(0); + } + + this.userSessions.set(userId, { + dataKey: Buffer.from(DEK), + expiresAt: now + sessionDurationMs, + }); + + DEK.fill(0); + return true; + } + const kekSalt = await this.getKEKSalt(userId); const encryptedDEK = await this.getEncryptedDEK(userId); @@ -201,7 +234,12 @@ class UserCrypto { DEK.fill(0); return true; - } catch { + } catch (error) { + databaseLogger.error("OIDC authentication failed", error, { + operation: "oidc_auth_error", + userId, + error: error instanceof Error ? error.message : "Unknown", + }); await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } @@ -327,25 +365,21 @@ class UserCrypto { } /** - * Convert a password-based user's encryption to OIDC encryption. + * Convert a password-based user's encryption to DUAL-AUTH encryption. * This is used when linking an OIDC account to a password account for dual-auth. * - * If user has no existing encryption setup, this does nothing. - * If user has encryption but no active session, we delete the old keys - * and let OIDC create new ones on next login (data will be lost). - * If user has an active session, we re-encrypt their DEK with OIDC system key (data preserved). + * IMPORTANT: This does NOT delete the password-based KEK salt! + * The user needs to maintain BOTH password and OIDC authentication methods. + * We keep the password KEK salt so password login still works. + * We also store the DEK encrypted with OIDC system key for OIDC login. */ async convertToOIDCEncryption(userId: string): Promise { try { - const { getDb } = await import("../database/db/index.js"); - const { settings } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); - const existingEncryptedDEK = await this.getEncryptedDEK(userId); const existingKEKSalt = await this.getKEKSalt(userId); if (!existingEncryptedDEK && !existingKEKSalt) { - databaseLogger.info("No existing encryption to convert for user", { + databaseLogger.warn("No existing encryption to convert for user", { operation: "convert_to_oidc_encryption_skip", userId, }); @@ -354,42 +388,45 @@ class UserCrypto { const existingDEK = this.getUserDataKey(userId); - if (existingDEK) { - const systemKey = this.deriveOIDCSystemKey(userId); - const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); - systemKey.fill(0); - - await this.storeEncryptedDEK(userId, oidcEncryptedDEK); - - databaseLogger.info( - "Converted user encryption from password to OIDC (data preserved)", - { - operation: "convert_to_oidc_encryption_preserved", - userId, - }, - ); - } else { - if (existingEncryptedDEK) { - await getDb() - .delete(settings) - .where(eq(settings.key, `user_encrypted_dek_${userId}`)); - } - - databaseLogger.warn( - "Deleted old encryption keys during OIDC conversion - user data may be lost", - { - operation: "convert_to_oidc_encryption_data_loss", - userId, - }, + if (!existingDEK) { + throw new Error( + "Cannot convert to OIDC encryption - user session not active. Please log in with password first.", ); } - if (existingKEKSalt) { + const systemKey = this.deriveOIDCSystemKey(userId); + const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); + systemKey.fill(0); + + const key = `user_encrypted_dek_oidc_${userId}`; + const value = JSON.stringify(oidcEncryptedDEK); + + const { getDb } = await import("../database/db/index.js"); + const { settings } = await import("../database/db/schema.js"); + const { eq } = await import("drizzle-orm"); + + const existing = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (existing.length > 0) { await getDb() - .delete(settings) - .where(eq(settings.key, `user_kek_salt_${userId}`)); + .update(settings) + .set({ value }) + .where(eq(settings.key, key)); + } else { + await getDb().insert(settings).values({ key, value }); } + databaseLogger.info( + "Converted user encryption to dual-auth (password + OIDC)", + { + operation: "convert_to_oidc_encryption_preserved", + userId, + }, + ); + const { saveMemoryDatabaseToFile } = await import( "../database/db/index.js" ); @@ -582,6 +619,26 @@ class UserCrypto { return null; } } + + private async getOIDCEncryptedDEK( + userId: string, + ): Promise { + try { + const key = `user_encrypted_dek_oidc_${userId}`; + const result = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (result.length === 0) { + return null; + } + + return JSON.parse(result[0].value); + } catch { + return null; + } + } } export { UserCrypto, type KEKSalt, type EncryptedDEK }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e9e0632e..60e2ec3a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -510,6 +510,10 @@ "accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}", "failedToLinkAccounts": "Failed to link accounts", "linkTargetUsernameRequired": "Target username is required", + "unlinkOIDCTitle": "Unlink OIDC Authentication", + "unlinkOIDCDescription": "Remove OIDC authentication from {{username}}? The user will only be able to login with username/password after this.", + "unlinkOIDCSuccess": "OIDC unlinked from {{username}}", + "failedToUnlinkOIDC": "Failed to unlink OIDC", "databaseSecurity": "Database Security", "encryptionStatus": "Encryption Status", "encryptionEnabled": "Encryption Enabled", @@ -1576,6 +1580,7 @@ "authMethod": "Authentication Method", "local": "Local", "external": "External (OIDC)", + "externalAndLocal": "Dual Auth", "selectPreferredLanguage": "Select your preferred language for the interface", "fileColorCoding": "File Color Coding", "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 2fabfaae..fb015997 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -183,13 +183,13 @@ function AppContent() { } return ( -
+
{!isAuthenticated && ( -
+
TERMIX @@ -342,6 +352,7 @@ function AppContent() { style={{ animation: "subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards", + willChange: "opacity, transform", }} > SSH SERVER MANAGER @@ -370,23 +381,19 @@ function AppContent() { @keyframes logoFade { 0% { opacity: 0; - transform: scale(0.85); - filter: blur(8px); + transform: scale(0.85) translateZ(0); } 25% { opacity: 1; - transform: scale(1); - filter: blur(0px); + transform: scale(1) translateZ(0); } 75% { opacity: 1; - transform: scale(1); - filter: blur(0px); + transform: scale(1) translateZ(0); } 100% { opacity: 0; - transform: scale(1.05); - filter: blur(4px); + transform: scale(1.05) translateZ(0); } } @keyframes logoGlow { @@ -416,19 +423,19 @@ function AppContent() { @keyframes subtitleFade { 0%, 30% { opacity: 0; - transform: translateY(10px); + transform: translateY(10px) translateZ(0); } 50% { opacity: 1; - transform: translateY(0); + transform: translateY(0) translateZ(0); } 75% { opacity: 1; - transform: translateY(0); + transform: translateY(0) translateZ(0); } 100% { opacity: 0; - transform: translateY(-5px); + transform: translateY(-5px) translateZ(0); } } `} diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 0de0dd8a..9febd08a 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -695,28 +695,28 @@ export function AdminSettings({ }; const handleUnlinkOIDC = async (userId: string, username: string) => { - const confirmed = await confirm( - { - title: "Unlink OIDC Authentication", - description: `Remove OIDC authentication from ${username}? The user will only be able to login with username/password after this.`, + confirmWithToast( + t("admin.unlinkOIDCDescription", { username }), + async () => { + try { + const result = await unlinkOIDCFromPasswordAccount(userId); + + toast.success( + result.message || t("admin.unlinkOIDCSuccess", { username }), + ); + fetchUsers(); + fetchSessions(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { error?: string; code?: string } }; + }; + toast.error( + err.response?.data?.error || t("admin.failedToUnlinkOIDC"), + ); + } }, - "default", + "destructive", ); - - if (!confirmed) return; - - try { - const result = await unlinkOIDCFromPasswordAccount(userId); - - toast.success(result.message || `OIDC unlinked from ${username}`); - fetchUsers(); - fetchSessions(); - } catch (error: unknown) { - const err = error as { - response?: { data?: { error?: string; code?: string } }; - }; - toast.error(err.response?.data?.error || "Failed to unlink OIDC"); - } }; const topMarginPx = isTopbarOpen ? 74 : 26; diff --git a/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx index 701c5429..2d88a4f2 100644 --- a/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx +++ b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx @@ -153,7 +153,6 @@ export function PermissionsDialog({
- {/* Current info */}
- {/* Owner permissions */}
- {/* Group permissions */}
- {/* Others permissions */}