diff --git a/README.md b/README.md index 704f4c17..7f08b9f1 100644 --- a/README.md +++ b/README.md @@ -45,22 +45,25 @@ If you would like, you can support the project here!\ Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal -access, SSH tunneling capabilities, and remote file management, with many more tools to come. +access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect +free and self-hosted alternative to Termius available for all platforms. # Features -- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system +- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components - **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring -- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly. +- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly - **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 HDD usage on any SSH server -- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support -- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption -- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync +- **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 +- **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, and German -- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android. macOS and iPadOS support is planned. +- **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. # Planned Features @@ -70,12 +73,26 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe Supported Devices: -- Website (any modern browser like Google, Safari, and Firefox) -- Windows (app) -- Linux (app) -- iOS (app) -- Android (app) -- iPadOS and macOS are in progress +- Website (any modern browser on any platform like Chrome, Safari, and Firefox) +- Windows (x64/ia32) + - Portable EXE + - MSI Installer + - Chocolatey Package Manager +- Linux (x64/ia32) + - Portable EXE + - Appimage + - Deb + - Flatpak +- macOS (x64/ia32 on v12.0+) + - Apple App Store + - DMG + - Homebrew +- iOS (v15.1+) + - Apple App Store + - ISO +- Android (v7.0+) + - Google Play Store + - APK Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view a sample Docker Compose file here: diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 13a74ea9..45707d8d 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -146,6 +146,18 @@ async function initializeCompleteDatabase(): Promise { value TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + jwt_token TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + CREATE TABLE IF NOT EXISTS ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -415,6 +427,37 @@ const migrateSchema = () => { addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); + // Create sessions table if it doesn't exist (for existing databases) + try { + sqlite + .prepare("SELECT id FROM sessions LIMIT 1") + .get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + jwt_token TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + `); + databaseLogger.info("Sessions table created via migration", { + operation: "schema_migration", + }); + } catch (createError) { + databaseLogger.warn("Failed to create sessions table", { + operation: "schema_migration", + error: createError, + }); + } + } + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index d00fa94e..8a1f3c89 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -30,6 +30,23 @@ export const settings = sqliteTable("settings", { value: text("value").notNull(), }); +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + jwtToken: text("jwt_token").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastActiveAt: text("last_active_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const sshData = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index f1a18326..6b25ebbe 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -4,6 +4,7 @@ import crypto from "crypto"; import { db } from "../db/index.js"; import { users, + sessions, sshData, sshCredentials, fileManagerRecent, @@ -25,6 +26,7 @@ import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; +import { parseUserAgent } from "../../utils/user-agent-parser.js"; const authManager = AuthManager.getInstance(); @@ -810,8 +812,18 @@ router.get("/oidc/callback", async (req, res) => { }); } + // Detect platform and device info + const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { - expiresIn: "50d", + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, + }); + + authLogger.success("OIDC user authenticated", { + operation: "oidc_login_success", + userId: userRecord.id, + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, }); let frontendUrl = (redirectUri as string).replace( @@ -826,12 +838,14 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set("success", "true"); + // Calculate max age based on device type + const maxAge = + deviceInfo.type === "desktop" || deviceInfo.type === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + return res - .cookie( - "jwt", - token, - authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000), - ) + .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); } catch (err) { authLogger.error("OIDC callback failed", err); @@ -951,8 +965,11 @@ router.post("/login", async (req, res) => { }); } + // Detect platform and device info + const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { - expiresIn: "7d", + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, }); authLogger.success(`User logged in successfully: ${username}`, { @@ -960,6 +977,8 @@ router.post("/login", async (req, res) => { username, userId: userRecord.id, dataUnlocked: true, + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, }); const response: Record = { @@ -976,12 +995,14 @@ router.post("/login", async (req, res) => { response.token = token; } + // Calculate max age based on device type + const maxAge = + deviceInfo.type === "desktop" || deviceInfo.type === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + return res - .cookie( - "jwt", - token, - authManager.getSecureCookieOptions(req, 7 * 24 * 60 * 60 * 1000), - ) + .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .json(response); } catch (err) { authLogger.error("Failed to log in user", err); @@ -1793,8 +1814,11 @@ router.post("/totp/verify-login", async (req, res) => { .where(eq(users.id, userRecord.id)); } + // Detect platform and device info + const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { - expiresIn: "50d", + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, }); const isElectron = @@ -1810,6 +1834,13 @@ router.post("/totp/verify-login", async (req, res) => { }); } + authLogger.success("TOTP verification successful", { + operation: "totp_verify_success", + userId: userRecord.id, + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, + }); + const response: Record = { success: true, is_admin: !!userRecord.is_admin, @@ -1824,12 +1855,14 @@ router.post("/totp/verify-login", async (req, res) => { response.token = token; } + // Calculate max age based on device type + const maxAge = + deviceInfo.type === "desktop" || deviceInfo.type === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + return res - .cookie( - "jwt", - token, - authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000), - ) + .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .json(response); } catch (err) { authLogger.error("TOTP verification failed", err); @@ -2093,6 +2126,10 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { const targetUserId = targetUser[0].id; try { + // Delete all user-related data to avoid foreign key constraints + await db + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, targetUserId)); await db .delete(fileManagerRecent) .where(eq(fileManagerRecent.userId, targetUserId)); @@ -2102,12 +2139,17 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { await db .delete(fileManagerShortcuts) .where(eq(fileManagerShortcuts.userId, targetUserId)); - + await db + .delete(recentActivity) + .where(eq(recentActivity.userId, targetUserId)); await db .delete(dismissedAlerts) .where(eq(dismissedAlerts.userId, targetUserId)); - + await db.delete(snippets).where(eq(snippets.userId, targetUserId)); await db.delete(sshData).where(eq(sshData.userId, targetUserId)); + await db + .delete(sshCredentials) + .where(eq(sshCredentials.userId, targetUserId)); } catch (cleanupError) { authLogger.error(`Cleanup failed for user ${username}:`, cleanupError); throw cleanupError; @@ -2253,4 +2295,166 @@ router.post("/change-password", authenticateJWT, async (req, res) => { } }); +// Route: Get sessions (all for admin, own for user) +// GET /users/sessions +router.get("/sessions", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + let sessionList; + + if (userRecord.is_admin) { + // Admin: Get all sessions with user info + sessionList = await authManager.getAllSessions(); + + // Join with users to get usernames + const enrichedSessions = await Promise.all( + sessionList.map(async (session) => { + const sessionUser = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, session.userId)) + .limit(1); + + return { + ...session, + username: sessionUser[0]?.username || "Unknown", + }; + }), + ); + + return res.json({ sessions: enrichedSessions }); + } else { + // Regular user: Get only their own sessions + sessionList = await authManager.getUserSessions(userId); + return res.json({ sessions: sessionList }); + } + } catch (err) { + authLogger.error("Failed to get sessions", err); + res.status(500).json({ error: "Failed to get sessions" }); + } +}); + +// Route: Revoke a specific session +// DELETE /users/sessions/:sessionId +router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { sessionId } = req.params; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + // Check if session exists + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (sessionRecords.length === 0) { + return res.status(404).json({ error: "Session not found" }); + } + + const session = sessionRecords[0]; + + // Non-admin users can only revoke their own sessions + if (!userRecord.is_admin && session.userId !== userId) { + return res + .status(403) + .json({ error: "Not authorized to revoke this session" }); + } + + const success = await authManager.revokeSession(sessionId); + + if (success) { + authLogger.success("Session revoked", { + operation: "session_revoke", + sessionId, + revokedBy: userId, + }); + res.json({ message: "Session revoked successfully" }); + } else { + res.status(500).json({ error: "Failed to revoke session" }); + } + } catch (err) { + authLogger.error("Failed to revoke session", err); + res.status(500).json({ error: "Failed to revoke session" }); + } +}); + +// Route: Revoke all sessions for a user +// POST /users/sessions/revoke-all +router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { targetUserId, exceptCurrent } = req.body; + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + + // Determine which user's sessions to revoke + let revokeUserId = userId; + if (targetUserId && userRecord.is_admin) { + // Admin can revoke any user's sessions + revokeUserId = targetUserId; + } else if (targetUserId && targetUserId !== userId) { + // Non-admin can only revoke their own sessions + return res.status(403).json({ + error: "Not authorized to revoke sessions for other users", + }); + } + + // Get current session ID if needed + let currentSessionId: string | undefined; + if (exceptCurrent) { + const token = + req.cookies?.jwt || req.headers?.authorization?.split(" ")[1]; + if (token) { + const payload = await authManager.verifyJWTToken(token); + currentSessionId = payload?.sessionId; + } + } + + const revokedCount = await authManager.revokeAllUserSessions( + revokeUserId, + currentSessionId, + ); + + authLogger.success("User sessions revoked", { + operation: "user_sessions_revoke_all", + revokeUserId, + revokedBy: userId, + exceptCurrent, + revokedCount, + }); + + res.json({ + message: `${revokedCount} session(s) revoked successfully`, + count: revokedCount, + }); + } catch (err) { + authLogger.error("Failed to revoke user sessions", err); + res.status(500).json({ error: "Failed to revoke sessions" }); + } +}); + export default router; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 2d00ce15..58e13b3e 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -311,6 +311,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, }; + let authMethodNotAvailable = false; + if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey && @@ -353,37 +355,30 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { // Use authHandler to control authentication flow // This ensures we only try keyboard-interactive, not password auth config.authHandler = ( - methodsLeft: string[], + methodsLeft: string[] | null, partialSuccess: boolean, callback: (nextMethod: string | false) => void, ) => { - fileLogger.info("Auth handler called", { - operation: "ssh_auth_handler", - hostId, - sessionId, - methodsLeft, - partialSuccess, - }); - - // Only try keyboard-interactive - if (methodsLeft.includes("keyboard-interactive")) { - callback("keyboard-interactive"); + if (methodsLeft && methodsLeft.length > 0) { + if (methodsLeft.includes("keyboard-interactive")) { + callback("keyboard-interactive"); + } else { + authMethodNotAvailable = true; + fileLogger.error( + "Server does not support keyboard-interactive auth", + { + operation: "ssh_auth_handler_no_keyboard", + hostId, + sessionId, + methodsAvailable: methodsLeft, + }, + ); + callback(false); + } } else { - fileLogger.error("Server does not support keyboard-interactive auth", { - operation: "ssh_auth_handler_no_keyboard", - hostId, - sessionId, - methodsLeft, - }); - callback(false); // No more methods to try + callback(false); } }; - - fileLogger.info("Using keyboard-interactive auth (authType: none)", { - operation: "ssh_auth_config", - hostId, - sessionId, - }); } else { fileLogger.warn( "No valid authentication method provided for file manager", @@ -446,13 +441,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, }, ); - - fileLogger.info("File manager activity logged", { - operation: "activity_log", - userId, - hostId, - hostName, - }); } catch (error) { fileLogger.warn("Failed to log file manager activity", { operation: "activity_log_error", @@ -468,16 +456,34 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { client.on("error", (err) => { if (responseSent) return; responseSent = true; - fileLogger.error("SSH connection failed for file manager", { - operation: "file_connect", - sessionId, - hostId, - ip, - port, - username, - error: err.message, - }); - res.status(500).json({ status: "error", message: err.message }); + + if (authMethodNotAvailable && resolvedCredentials.authType === "none") { + fileLogger.info( + "Keyboard-interactive not available, requesting credentials", + { + operation: "file_connect_auth_not_available", + sessionId, + hostId, + }, + ); + res.status(200).json({ + status: "auth_required", + message: + "The server does not support keyboard-interactive authentication. Please provide credentials.", + reason: "no_keyboard", + }); + } else { + fileLogger.error("SSH connection failed for file manager", { + operation: "file_connect", + sessionId, + hostId, + ip, + port, + username, + error: err.message, + }); + res.status(500).json({ status: "error", message: err.message }); + } }); client.on("close", () => { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 5c79a728..f28f5408 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -364,6 +364,55 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } + case "reconnect_with_credentials": { + const credentialsData = data as { + cols: number; + rows: number; + hostConfig: ConnectToHostData["hostConfig"]; + password?: string; + sshKey?: string; + keyPassword?: string; + }; + + // Update the host config with provided credentials + if (credentialsData.password) { + credentialsData.hostConfig.password = credentialsData.password; + credentialsData.hostConfig.authType = "password"; + } else if (credentialsData.sshKey) { + credentialsData.hostConfig.key = credentialsData.sshKey; + credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; + credentialsData.hostConfig.authType = "key"; + } + + // Cleanup existing connection if any + cleanupSSH(); + + // Reconnect with new credentials + const reconnectData: ConnectToHostData = { + cols: credentialsData.cols, + rows: credentialsData.rows, + hostConfig: credentialsData.hostConfig, + }; + + handleConnectToHost(reconnectData).catch((error) => { + sshLogger.error("Failed to reconnect with credentials", error, { + operation: "ssh_reconnect_with_credentials", + userId, + hostId: credentialsData.hostConfig?.id, + ip: credentialsData.hostConfig?.ip, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect with provided credentials: " + + (error instanceof Error ? error.message : "Unknown error"), + }), + ); + }); + break; + } + default: sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", @@ -741,6 +790,17 @@ wss.on("connection", async (ws: WebSocket, req) => { prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { + // Notify frontend that keyboard-interactive is available (e.g., for Warpgate OIDC) + // This allows the terminal to be displayed immediately so user can see auth prompts + if (resolvedCredentials.authType === "none") { + ws.send( + JSON.stringify({ + type: "keyboard_interactive_available", + message: "Keyboard-interactive authentication is available", + }), + ); + } + const promptTexts = prompts.map((p) => p.prompt); const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( @@ -931,37 +991,47 @@ wss.on("connection", async (ws: WebSocket, req) => { }; if (resolvedCredentials.authType === "none") { - // Use authHandler to control authentication flow - // This ensures we only try keyboard-interactive, not password auth + // For "none" auth type, allow natural SSH negotiation + // The authHandler will try keyboard-interactive if available, otherwise notify frontend + // This allows for Warpgate OIDC and other interactive auth scenarios connectConfig.authHandler = ( - methodsLeft: string[], + methodsLeft: string[] | null, partialSuccess: boolean, callback: (nextMethod: string | false) => void, ) => { - sshLogger.info("Auth handler called", { - operation: "ssh_auth_handler", - hostId: id, - methodsLeft, - partialSuccess, - }); - - // Only try keyboard-interactive - if (methodsLeft.includes("keyboard-interactive")) { - callback("keyboard-interactive"); + if (methodsLeft && methodsLeft.length > 0) { + // Prefer keyboard-interactive if available + if (methodsLeft.includes("keyboard-interactive")) { + callback("keyboard-interactive"); + } else { + // No keyboard-interactive available - notify frontend to show auth dialog + sshLogger.info( + "Server does not support keyboard-interactive auth for 'none' auth type", + { + operation: "ssh_auth_handler_no_keyboard", + hostId: id, + methodsLeft, + }, + ); + ws.send( + JSON.stringify({ + type: "auth_method_not_available", + message: + "The server does not support keyboard-interactive authentication. Please provide credentials.", + methodsAvailable: methodsLeft, + }), + ); + callback(false); + } } else { - sshLogger.error("Server does not support keyboard-interactive auth", { - operation: "ssh_auth_handler_no_keyboard", + // No methods left or empty - try to proceed without auth + sshLogger.info("No auth methods available, proceeding without auth", { + operation: "ssh_auth_no_methods", hostId: id, - methodsLeft, }); - callback(false); // No more methods to try + callback(false); } }; - - sshLogger.info("Using keyboard-interactive auth (authType: none)", { - operation: "ssh_auth_config", - hostId: id, - }); } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password) { sshLogger.error( diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index e4ffe714..62babdb7 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -4,6 +4,11 @@ import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; import type { Request, Response, NextFunction } from "express"; +import { db } from "../database/db/index.js"; +import { sessions } from "../database/db/schema.js"; +import { eq, and, sql } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import type { DeviceType } from "./user-agent-parser.js"; interface AuthenticationResult { success: boolean; @@ -18,6 +23,7 @@ interface AuthenticationResult { interface JWTPayload { userId: string; + sessionId?: string; pendingTOTP?: boolean; iat?: number; exp?: number; @@ -132,18 +138,101 @@ class AuthManager { async generateJWTToken( userId: string, - options: { expiresIn?: string; pendingTOTP?: boolean } = {}, + options: { + expiresIn?: string; + pendingTOTP?: boolean; + deviceType?: DeviceType; + deviceInfo?: string; + } = {}, ): Promise { const jwtSecret = await this.systemCrypto.getJWTSecret(); + // Determine expiration based on device type + let expiresIn = options.expiresIn; + if (!expiresIn && !options.pendingTOTP) { + if (options.deviceType === "desktop" || options.deviceType === "mobile") { + expiresIn = "30d"; // 30 days for desktop and mobile + } else { + expiresIn = "7d"; // 7 days for web + } + } else if (!expiresIn) { + expiresIn = "7d"; // Default + } + const payload: JWTPayload = { userId }; if (options.pendingTOTP) { payload.pendingTOTP = true; } - return jwt.sign(payload, jwtSecret, { - expiresIn: options.expiresIn || "7d", - } as jwt.SignOptions); + // Create session in database if not a temporary TOTP token + if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { + const sessionId = nanoid(); + payload.sessionId = sessionId; + + // Generate the token first to get it for storage + const token = jwt.sign(payload, jwtSecret, { + expiresIn, + } as jwt.SignOptions); + + // Calculate expiration timestamp + const expirationMs = this.parseExpiresIn(expiresIn); + const expiresAt = new Date(Date.now() + expirationMs).toISOString(); + + // Store session in database + try { + await db.insert(sessions).values({ + id: sessionId, + userId, + jwtToken: token, + deviceType: options.deviceType, + deviceInfo: options.deviceInfo, + expiresAt, + }); + + databaseLogger.info("Session created", { + operation: "session_create", + userId, + sessionId, + deviceType: options.deviceType, + expiresAt, + }); + } catch (error) { + databaseLogger.error("Failed to create session", error, { + operation: "session_create_failed", + userId, + sessionId, + }); + // Continue anyway - session tracking is non-critical + } + + return token; + } + + return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); + } + + /** + * Parse expiresIn string to milliseconds + */ + private parseExpiresIn(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)([smhd])$/); + if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days + + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case "s": + return value * 1000; + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return 7 * 24 * 60 * 60 * 1000; + } } async verifyJWTToken(token: string): Promise { @@ -175,6 +264,152 @@ class AuthManager { }); } + async revokeSession(sessionId: string): Promise { + try { + // Get the session to blacklist the token + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (sessionRecords.length > 0) { + const session = sessionRecords[0]; + this.invalidatedTokens.add(session.jwtToken); + } + + // Delete the session instead of marking as revoked + await db.delete(sessions).where(eq(sessions.id, sessionId)); + + databaseLogger.info("Session deleted", { + operation: "session_delete", + sessionId, + }); + + return true; + } catch (error) { + databaseLogger.error("Failed to delete session", error, { + operation: "session_delete_failed", + sessionId, + }); + return false; + } + } + + async revokeAllUserSessions( + userId: string, + exceptSessionId?: string, + ): Promise { + try { + // Get all user sessions to blacklist tokens + let query = db.select().from(sessions).where(eq(sessions.userId, userId)); + + const userSessions = await query; + + // Add all tokens to blacklist (except the excepted one) + for (const session of userSessions) { + if (!exceptSessionId || session.id !== exceptSessionId) { + this.invalidatedTokens.add(session.jwtToken); + } + } + + // Delete sessions instead of marking as revoked + if (exceptSessionId) { + await db + .delete(sessions) + .where( + and( + eq(sessions.userId, userId), + sql`${sessions.id} != ${exceptSessionId}`, + ), + ); + } else { + await db.delete(sessions).where(eq(sessions.userId, userId)); + } + + const deletedCount = userSessions.filter( + (s) => !exceptSessionId || s.id !== exceptSessionId, + ).length; + + databaseLogger.info("User sessions deleted", { + operation: "user_sessions_delete", + userId, + exceptSessionId, + deletedCount, + }); + + return deletedCount; + } catch (error) { + databaseLogger.error("Failed to delete user sessions", error, { + operation: "user_sessions_delete_failed", + userId, + }); + return 0; + } + } + + async cleanupExpiredSessions(): Promise { + try { + // Get expired sessions to blacklist their tokens + const expiredSessions = await db + .select() + .from(sessions) + .where(sql`${sessions.expiresAt} < datetime('now')`); + + // Add expired tokens to blacklist + for (const session of expiredSessions) { + this.invalidatedTokens.add(session.jwtToken); + } + + // Delete expired sessions + await db + .delete(sessions) + .where(sql`${sessions.expiresAt} < datetime('now')`); + + if (expiredSessions.length > 0) { + databaseLogger.info("Expired sessions cleaned up", { + operation: "sessions_cleanup", + count: expiredSessions.length, + }); + } + + return expiredSessions.length; + } catch (error) { + databaseLogger.error("Failed to cleanup expired sessions", error, { + operation: "sessions_cleanup_failed", + }); + return 0; + } + } + + async getAllSessions(): Promise { + try { + const allSessions = await db.select().from(sessions); + return allSessions; + } catch (error) { + databaseLogger.error("Failed to get all sessions", error, { + operation: "sessions_get_all_failed", + }); + return []; + } + } + + async getUserSessions(userId: string): Promise { + try { + const userSessions = await db + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + return userSessions; + } catch (error) { + databaseLogger.error("Failed to get user sessions", error, { + operation: "sessions_get_user_failed", + userId, + }); + return []; + } + } + getSecureCookieOptions( req: RequestWithHeaders, maxAge: number = 7 * 24 * 60 * 60 * 1000, @@ -210,6 +445,55 @@ class AuthManager { return res.status(401).json({ error: "Invalid token" }); } + // Check session status if sessionId is present + if (payload.sessionId) { + try { + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, payload.sessionId)) + .limit(1); + + if (sessionRecords.length === 0) { + return res.status(401).json({ + error: "Session not found", + code: "SESSION_NOT_FOUND", + }); + } + + const session = sessionRecords[0]; + + // Session exists, no need to check isRevoked since we delete sessions instead + + // Check if session has expired + if (new Date(session.expiresAt) < new Date()) { + return res.status(401).json({ + error: "Session has expired", + code: "SESSION_EXPIRED", + }); + } + + // Update lastActiveAt timestamp (async, non-blocking) + db.update(sessions) + .set({ lastActiveAt: new Date().toISOString() }) + .where(eq(sessions.id, payload.sessionId)) + .then(() => {}) + .catch((error) => { + databaseLogger.warn("Failed to update session lastActiveAt", { + operation: "session_update_last_active", + sessionId: payload.sessionId, + error: error instanceof Error ? error.message : "Unknown error", + }); + }); + } catch (error) { + databaseLogger.error("Session check failed", error, { + operation: "session_check_failed", + sessionId: payload.sessionId, + }); + // Continue anyway - session tracking failures shouldn't block auth + } + } + authReq.userId = payload.userId; authReq.pendingTOTP = payload.pendingTOTP; next(); diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts new file mode 100644 index 00000000..b84e969c --- /dev/null +++ b/src/backend/utils/user-agent-parser.ts @@ -0,0 +1,243 @@ +import type { Request } from "express"; + +export type DeviceType = "web" | "desktop" | "mobile"; + +export interface DeviceInfo { + type: DeviceType; + browser: string; + version: string; + os: string; + deviceInfo: string; // Formatted string like "Chrome 120 on Windows 11" +} + +/** + * Detect the platform type based on request headers + */ +export function detectPlatform(req: Request): DeviceType { + const userAgent = req.headers["user-agent"] || ""; + const electronHeader = req.headers["x-electron-app"]; + + // Electron app detection + if (electronHeader === "true") { + return "desktop"; + } + + // Mobile app detection + if (userAgent.includes("Termix-Mobile")) { + return "mobile"; + } + + // Default to web + return "web"; +} + +/** + * Parse User-Agent string to extract device information + */ +export function parseUserAgent(req: Request): DeviceInfo { + const userAgent = req.headers["user-agent"] || "Unknown"; + const platform = detectPlatform(req); + + // For Electron + if (platform === "desktop") { + return parseElectronUserAgent(userAgent); + } + + // For Mobile app + if (platform === "mobile") { + return parseMobileUserAgent(userAgent); + } + + // For web browsers + return parseWebUserAgent(userAgent); +} + +/** + * Parse Electron app user agent + */ +function parseElectronUserAgent(userAgent: string): DeviceInfo { + let os = "Unknown OS"; + let version = "Unknown"; + + // Detect OS + if (userAgent.includes("Windows")) { + os = parseWindowsVersion(userAgent); + } else if (userAgent.includes("Mac OS X")) { + os = parseMacVersion(userAgent); + } else if (userAgent.includes("Linux")) { + os = "Linux"; + } + + // Try to extract Electron version + const electronMatch = userAgent.match(/Electron\/([\d.]+)/); + if (electronMatch) { + version = electronMatch[1]; + } + + return { + type: "desktop", + browser: "Electron", + version, + os, + deviceInfo: `Termix Desktop on ${os}`, + }; +} + +/** + * Parse mobile app user agent + */ +function parseMobileUserAgent(userAgent: string): DeviceInfo { + let os = "Unknown OS"; + let version = "Unknown"; + + // Detect mobile OS + if (userAgent.includes("Android")) { + const androidMatch = userAgent.match(/Android ([\d.]+)/); + os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + const iosMatch = userAgent.match(/OS ([\d_]+)/); + if (iosMatch) { + const iosVersion = iosMatch[1].replace(/_/g, "."); + os = `iOS ${iosVersion}`; + } else { + os = "iOS"; + } + } + + // Try to extract app version (if included in UA) + const versionMatch = userAgent.match(/Termix-Mobile\/([\d.]+)/); + if (versionMatch) { + version = versionMatch[1]; + } + + return { + type: "mobile", + browser: "Termix Mobile", + version, + os, + deviceInfo: `Termix Mobile on ${os}`, + }; +} + +/** + * Parse web browser user agent + */ +function parseWebUserAgent(userAgent: string): DeviceInfo { + let browser = "Unknown Browser"; + let version = "Unknown"; + let os = "Unknown OS"; + + // Detect browser + if (userAgent.includes("Edg/")) { + const match = userAgent.match(/Edg\/([\d.]+)/); + browser = "Edge"; + version = match ? match[1] : "Unknown"; + } else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) { + const match = userAgent.match(/Chrome\/([\d.]+)/); + browser = "Chrome"; + version = match ? match[1] : "Unknown"; + } else if (userAgent.includes("Firefox/")) { + const match = userAgent.match(/Firefox\/([\d.]+)/); + browser = "Firefox"; + version = match ? match[1] : "Unknown"; + } else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) { + const match = userAgent.match(/Version\/([\d.]+)/); + browser = "Safari"; + version = match ? match[1] : "Unknown"; + } else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) { + const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/); + browser = "Opera"; + version = match ? match[1] : "Unknown"; + } + + // Detect OS + if (userAgent.includes("Windows")) { + os = parseWindowsVersion(userAgent); + } else if (userAgent.includes("Mac OS X")) { + os = parseMacVersion(userAgent); + } else if (userAgent.includes("Linux")) { + os = "Linux"; + } else if (userAgent.includes("Android")) { + const match = userAgent.match(/Android ([\d.]+)/); + os = match ? `Android ${match[1]}` : "Android"; + } else if ( + userAgent.includes("iOS") || + userAgent.includes("iPhone") || + userAgent.includes("iPad") + ) { + const match = userAgent.match(/OS ([\d_]+)/); + if (match) { + const iosVersion = match[1].replace(/_/g, "."); + os = `iOS ${iosVersion}`; + } else { + os = "iOS"; + } + } + + // Shorten version to major.minor + if (version !== "Unknown") { + const versionParts = version.split("."); + version = versionParts.slice(0, 2).join("."); + } + + return { + type: "web", + browser, + version, + os, + deviceInfo: `${browser} ${version} on ${os}`, + }; +} + +/** + * Parse Windows version from user agent + */ +function parseWindowsVersion(userAgent: string): string { + if (userAgent.includes("Windows NT 10.0")) { + return "Windows 10/11"; + } else if (userAgent.includes("Windows NT 6.3")) { + return "Windows 8.1"; + } else if (userAgent.includes("Windows NT 6.2")) { + return "Windows 8"; + } else if (userAgent.includes("Windows NT 6.1")) { + return "Windows 7"; + } else if (userAgent.includes("Windows NT 6.0")) { + return "Windows Vista"; + } else if ( + userAgent.includes("Windows NT 5.1") || + userAgent.includes("Windows NT 5.2") + ) { + return "Windows XP"; + } + return "Windows"; +} + +/** + * Parse macOS version from user agent + */ +function parseMacVersion(userAgent: string): string { + const match = userAgent.match(/Mac OS X ([\d_]+)/); + if (match) { + const version = match[1].replace(/_/g, "."); + const parts = version.split("."); + const major = parseInt(parts[0]); + const minor = parseInt(parts[1]); + + // macOS naming + if (major === 10) { + if (minor >= 15) return `macOS ${major}.${minor}`; + if (minor === 14) return "macOS Mojave"; + if (minor === 13) return "macOS High Sierra"; + if (minor === 12) return "macOS Sierra"; + } else if (major >= 11) { + return `macOS ${major}`; + } + + return `macOS ${version}`; + } + return "macOS"; +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index a2eeea3f..32198af9 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1306,7 +1306,8 @@ "deleteAccount": "Konto löschen", "closeDeleteAccount": "Schließen Konto löschen", "deleteAccountWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden Ihr Konto und alle damit verbundenen Daten dauerhaft gelöscht.", - "deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion ist nicht rückgängig zu machen.", + "deleteAccountWarningShort": "Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr Konto dauerhaft.", "cannotDeleteAccount": "Konto kann nicht gelöscht werden", "lastAdminWarning": "Sie sind der letzte Administrator. Sie können Ihr Konto nicht löschen, da das System dann ohne Administratoren wäre. Bitte benennen Sie zunächst einen anderen Benutzer als Administrator oder wenden Sie sich an den Systemsupport.", "confirmPassword": "Passwort bestätigen", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 60ca56cd..d8838358 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -247,7 +247,11 @@ "saveError": "Error saving configuration", "saving": "Saving...", "saveConfig": "Save Configuration", - "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)" + "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)", + "warning": "Warning", + "notValidatedWarning": "URL not validated - ensure it's correct", + "changeServer": "Change Server", + "mustIncludeProtocol": "Server URL must start with http:// or https://" }, "versionCheck": { "error": "Version Check Error", @@ -467,6 +471,13 @@ "userDeletedSuccessfully": "User {{username}} deleted successfully", "failedToDeleteUser": "Failed to delete user", "overrideUserInfoUrl": "Override User Info URL (not required)", + "failedToFetchSessions": "Failed to fetch sessions", + "sessionRevokedSuccessfully": "Session revoked successfully", + "failedToRevokeSession": "Failed to revoke session", + "confirmRevokeSession": "Are you sure you want to revoke this session?", + "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", + "failedToRevokeSessions": "Failed to revoke sessions", + "sessionsRevokedSuccessfully": "Sessions revoked successfully", "databaseSecurity": "Database Security", "encryptionStatus": "Encryption Status", "encryptionEnabled": "Encryption Enabled", @@ -1297,6 +1308,13 @@ "confirmNewPassword": "Confirm Password", "enterNewPassword": "Enter your new password for user:", "signUp": "Sign Up", + "mobileApp": "Mobile App", + "loggingInToMobileApp": "Logging in to the mobile app", + "desktopApp": "Desktop App", + "loggingInToDesktopApp": "Logging in to the desktop app", + "loggingInToDesktopAppViaWeb": "Logging in to the desktop app via web interface", + "loadingServer": "Loading server...", + "authenticating": "Authenticating...", "dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.", "authenticationDisabled": "Authentication Disabled", "authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator." @@ -1433,6 +1451,7 @@ "closeDeleteAccount": "Close Delete Account", "deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.", "deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.", + "deleteAccountWarningShort": "This action is not reversible and will permanently delete your account.", "cannotDeleteAccount": "Cannot Delete Account", "lastAdminWarning": "You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support.", "confirmPassword": "Confirm Password", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 646609ef..55570712 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -1353,6 +1353,7 @@ "closeDeleteAccount": "Fechar Exclusão de Conta", "deleteAccountWarning": "Esta ação não pode ser desfeita. Isso excluirá permanentemente sua conta e todos os dados associados.", "deleteAccountWarningDetails": "Excluir sua conta removerá todos os seus dados, incluindo hosts SSH, configurações e preferências. Esta ação é irreversível.", + "deleteAccountWarningShort": "Esta ação é irreversível e excluirá permanentemente sua conta.", "cannotDeleteAccount": "Não é Possível Excluir Conta", "lastAdminWarning": "Você é o último usuário administrador. Você não pode excluir sua conta pois isso deixaria o sistema sem administradores. Por favor, torne outro usuário administrador primeiro, ou contate o suporte do sistema.", "confirmPassword": "Confirmar Senha", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index c5a17c9e..69928ee1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1414,6 +1414,7 @@ "closeDeleteAccount": "关闭删除账户", "deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。", "deleteAccountWarningDetails": "删除您的账户将删除所有数据,包括 SSH 主机、配置和设置。此操作不可逆。", + "deleteAccountWarningShort": "此操作不可逆,将永久删除您的帐户。", "cannotDeleteAccount": "无法删除账户", "lastAdminWarning": "您是最后一个管理员用户。您不能删除自己的账户,否则系统将没有任何管理员。请先将其他用户设为管理员,或联系系统支持。", "confirmPassword": "确认密码", diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 93882e4c..ca31724f 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -29,6 +29,10 @@ import { Lock, Download, Upload, + Monitor, + Smartphone, + Globe, + Clock, } from "lucide-react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -111,6 +115,21 @@ export function AdminSettings({ const [showPasswordInput, setShowPasswordInput] = React.useState(false); const [importPassword, setImportPassword] = React.useState(""); + const [sessions, setSessions] = React.useState< + Array<{ + id: string; + userId: string; + username?: string; + deviceType: string; + deviceInfo: string; + createdAt: string; + expiresAt: string; + lastActiveAt: string; + jwtToken: string; + }> + >([]); + const [sessionsLoading, setSessionsLoading] = React.useState(false); + const requiresImportPassword = React.useMemo( () => !currentUser?.is_oidc, [currentUser?.is_oidc], @@ -152,6 +171,7 @@ export function AdminSettings({ } }); fetchUsers(); + fetchSessions(); }, []); React.useEffect(() => { @@ -538,6 +558,168 @@ export function AdminSettings({ } }; + const fetchSessions = async () => { + if (isElectron()) { + const serverUrl = (window as { configuredServerUrl?: string }) + .configuredServerUrl; + if (!serverUrl) { + return; + } + } + + setSessionsLoading(true); + try { + const isDev = + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === "" || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"); + + const apiUrl = isElectron() + ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions` + : isDev + ? `http://localhost:30001/users/sessions` + : `/users/sessions`; + + const response = await fetch(apiUrl, { + method: "GET", + credentials: "include", + headers: { + Authorization: `Bearer ${getCookie("jwt")}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setSessions(data.sessions || []); + } else { + toast.error(t("admin.failedToFetchSessions")); + } + } catch (err) { + if (!err?.message?.includes("No server configured")) { + toast.error(t("admin.failedToFetchSessions")); + } + } finally { + setSessionsLoading(false); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + // Check if this is the current session + const currentJWT = getCookie("jwt"); + const currentSession = sessions.find((s) => s.jwtToken === currentJWT); + const isCurrentSession = currentSession?.id === sessionId; + + confirmWithToast( + t("admin.confirmRevokeSession"), + async () => { + try { + const isDev = + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === "" || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"); + + const apiUrl = isElectron() + ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/${sessionId}` + : isDev + ? `http://localhost:30001/users/sessions/${sessionId}` + : `/users/sessions/${sessionId}`; + + const response = await fetch(apiUrl, { + method: "DELETE", + credentials: "include", + headers: { + Authorization: `Bearer ${getCookie("jwt")}`, + }, + }); + + if (response.ok) { + toast.success(t("admin.sessionRevokedSuccessfully")); + + // If user revoked their own session, reload the page after a brief delay + if (isCurrentSession) { + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + fetchSessions(); + } + } else { + toast.error(t("admin.failedToRevokeSession")); + } + } catch { + toast.error(t("admin.failedToRevokeSession")); + } + }, + "destructive", + ); + }; + + const handleRevokeAllUserSessions = async (userId: string) => { + // Check if revoking sessions for current user + const isCurrentUser = currentUser?.id === userId; + + confirmWithToast( + t("admin.confirmRevokeAllSessions"), + async () => { + try { + const isDev = + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === "" || + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"); + + const apiUrl = isElectron() + ? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/users/sessions/revoke-all` + : isDev + ? `http://localhost:30001/users/sessions/revoke-all` + : `/users/sessions/revoke-all`; + + const response = await fetch(apiUrl, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getCookie("jwt")}`, + }, + body: JSON.stringify({ + targetUserId: userId, + exceptCurrent: false, + }), + }); + + if (response.ok) { + const data = await response.json(); + toast.success( + data.message || t("admin.sessionsRevokedSuccessfully"), + ); + + // If revoking sessions for current user, reload the page after a brief delay + if (isCurrentUser) { + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + fetchSessions(); + } + } else { + toast.error(t("admin.failedToRevokeSessions")); + } + } catch { + toast.error(t("admin.failedToRevokeSessions")); + } + }, + "destructive", + ); + }; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; @@ -578,6 +760,10 @@ export function AdminSettings({ {t("admin.users")} + + + Sessions + {t("admin.adminManagement")} @@ -944,6 +1130,137 @@ export function AdminSettings({ + +
+
+

Session Management

+ +
+ {sessionsLoading ? ( +
+ Loading sessions... +
+ ) : sessions.length === 0 ? ( +
+ No active sessions found. +
+ ) : ( +
+ + + + Device + User + Created + Last Active + Expires + + {t("admin.actions")} + + + + + {sessions.map((session) => { + const DeviceIcon = + session.deviceType === "desktop" + ? Monitor + : session.deviceType === "mobile" + ? Smartphone + : Globe; + + const createdDate = new Date(session.createdAt); + const lastActiveDate = new Date(session.lastActiveAt); + const expiresDate = new Date(session.expiresAt); + + const formatDate = (date: Date) => + date.toLocaleDateString() + + " " + + date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + +
+ +
+ + {session.deviceInfo} + + {session.isRevoked && ( + + Revoked + + )} +
+
+
+ + {session.username || session.userId} + + + {formatDate(createdDate)} + + + {formatDate(lastActiveDate)} + + + {formatDate(expiresDate)} + + +
+ + {session.username && ( + + )} +
+
+
+ ); + })} +
+
+
+ )} +
+
+

diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index 6562ed0c..55d3fd4d 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx"; +import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx"; import { Upload, FolderPlus, @@ -100,6 +101,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [totpRequired, setTotpRequired] = useState(false); const [totpSessionId, setTotpSessionId] = useState(null); const [totpPrompt, setTotpPrompt] = useState(""); + const [showAuthDialog, setShowAuthDialog] = useState(false); + const [authDialogReason, setAuthDialogReason] = useState< + "no_keyboard" | "auth_failed" | "timeout" + >("no_keyboard"); const [pinnedFiles, setPinnedFiles] = useState>(new Set()); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [isClosing, setIsClosing] = useState(false); @@ -327,6 +332,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { return; } + if (result?.status === "auth_required") { + setAuthDialogReason(result.reason || "no_keyboard"); + setShowAuthDialog(true); + setIsLoading(false); + return; + } + setSshSessionId(sessionId); try { @@ -1315,6 +1327,80 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { if (onClose) onClose(); } + async function handleAuthDialogSubmit(credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) { + if (!currentHost) return; + + try { + setIsLoading(true); + setShowAuthDialog(false); + + const sessionId = currentHost.id.toString(); + + const result = await connectSSH(sessionId, { + hostId: currentHost.id, + ip: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + password: credentials.password, + sshKey: credentials.sshKey, + keyPassword: credentials.keyPassword, + authType: credentials.password ? "password" : "key", + credentialId: currentHost.credentialId, + userId: currentHost.userId, + }); + + if (result?.requires_totp) { + setTotpRequired(true); + setTotpSessionId(sessionId); + setTotpPrompt(result.prompt || "Verification code:"); + setIsLoading(false); + return; + } + + if (result?.status === "auth_required") { + setAuthDialogReason(result.reason || "auth_failed"); + setShowAuthDialog(true); + setIsLoading(false); + toast.error(t("fileManager.authenticationFailed")); + return; + } + + setSshSessionId(sessionId); + + try { + const response = await listSSHFiles(sessionId, currentPath); + const files = Array.isArray(response) + ? response + : response?.files || []; + setFiles(files); + clearSelection(); + initialLoadDoneRef.current = true; + toast.success(t("fileManager.connectedSuccessfully")); + logFileManagerActivity(); + } catch (dirError: unknown) { + console.error("Failed to load initial directory:", dirError); + } + } catch (error: unknown) { + console.error("SSH connection with credentials failed:", error); + setAuthDialogReason("auth_failed"); + setShowAuthDialog(true); + toast.error( + t("fileManager.failedToConnect") + ": " + (error.message || error), + ); + } finally { + setIsLoading(false); + } + } + + function handleAuthDialogCancel() { + setShowAuthDialog(false); + if (onClose) onClose(); + } + function generateUniqueName( baseName: string, type: "file" | "directory", @@ -1890,6 +1976,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onSubmit={handleTotpSubmit} onCancel={handleTotpCancel} /> + + {currentHost && ( + + )}

); } diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index cabce47b..ce58884a 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -19,6 +19,7 @@ import { getSnippets, } from "@/ui/main-axios.ts"; import { TOTPDialog } from "@/ui/Desktop/Navigation/TOTPDialog.tsx"; +import { SSHAuthDialog } from "@/ui/Desktop/Navigation/SSHAuthDialog.tsx"; import { TERMINAL_THEMES, DEFAULT_TERMINAL_CONFIG, @@ -104,6 +105,12 @@ export const Terminal = forwardRef( const [totpRequired, setTotpRequired] = useState(false); const [totpPrompt, setTotpPrompt] = useState(""); const [isPasswordPrompt, setIsPasswordPrompt] = useState(false); + const [showAuthDialog, setShowAuthDialog] = useState(false); + const [authDialogReason, setAuthDialogReason] = useState< + "no_keyboard" | "auth_failed" | "timeout" + >("no_keyboard"); + const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = + useState(false); const isVisibleRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); @@ -237,6 +244,38 @@ export const Terminal = forwardRef( if (onClose) onClose(); } + function handleAuthDialogSubmit(credentials: { + password?: string; + sshKey?: string; + keyPassword?: string; + }) { + if (webSocketRef.current && terminal) { + // Send reconnect message with credentials + webSocketRef.current.send( + JSON.stringify({ + type: "reconnect_with_credentials", + data: { + cols: terminal.cols, + rows: terminal.rows, + hostConfig: { + ...hostConfig, + password: credentials.password, + key: credentials.sshKey, + keyPassword: credentials.keyPassword, + }, + }, + }), + ); + setShowAuthDialog(false); + setIsConnecting(true); + } + } + + function handleAuthDialogCancel() { + setShowAuthDialog(false); + if (onClose) onClose(); + } + function scheduleNotify(cols: number, rows: number) { if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; @@ -635,6 +674,25 @@ export const Terminal = forwardRef( clearTimeout(connectionTimeoutRef.current); connectionTimeoutRef.current = null; } + } else if (msg.type === "keyboard_interactive_available") { + // Keyboard-interactive auth is available (e.g., Warpgate OIDC) + // Show terminal immediately so user can see auth prompts + setKeyboardInteractiveDetected(true); + setIsConnecting(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } + } else if (msg.type === "auth_method_not_available") { + // Server doesn't support keyboard-interactive for "none" auth + // Show SSHAuthDialog for manual credential entry + setAuthDialogReason("no_keyboard"); + setShowAuthDialog(true); + setIsConnecting(false); + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current); + connectionTimeoutRef.current = null; + } } } catch { toast.error(t("terminal.messageParseError")); @@ -1041,6 +1099,20 @@ export const Terminal = forwardRef( backgroundColor={backgroundColor} /> + + {isConnecting && (
{ setLoggedIn: (loggedIn: boolean) => void; @@ -586,6 +588,43 @@ export function Auth({ ); } + // Show ElectronLoginForm when Electron has a configured server and user is not logged in + if (isElectron() && currentServerUrl && !loggedIn && !authLoading) { + return ( +
+
+ { + try { + const meRes = await getUserInfo(); + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + toast.success(t("messages.loginSuccess")); + } catch (err) { + toast.error(t("errors.failedUserInfo")); + } + }} + onChangeServer={() => { + setShowServerConfig(true); + }} + /> +
+
+ ); + } + if (dbHealthChecking && !dbConnectionFailed) { return (
{ + try { + // Check if we're in an iframe AND the parent is Electron + if (window.self !== window.top) { + // We're in an iframe, likely Electron's ElectronLoginForm + return true; + } + } catch (e) { + // Cross-origin iframe, can't access parent + return false; + } + return false; + }; + return (
+ {isInElectronWebView() && ( + + + {t("auth.desktopApp")} + {t("auth.loggingInToDesktopApp")} + + )} {totpRequired && (
diff --git a/src/ui/Desktop/Authentication/ElectronLoginForm.tsx b/src/ui/Desktop/Authentication/ElectronLoginForm.tsx new file mode 100644 index 00000000..005241c1 --- /dev/null +++ b/src/ui/Desktop/Authentication/ElectronLoginForm.tsx @@ -0,0 +1,335 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button.tsx"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react"; +import { getCookie } from "@/ui/main-axios.ts"; + +interface ElectronLoginFormProps { + serverUrl: string; + onAuthSuccess: () => void; + onChangeServer: () => void; +} + +export function ElectronLoginForm({ + serverUrl, + onAuthSuccess, + onChangeServer, +}: ElectronLoginFormProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const iframeRef = useRef(null); + const hasAuthenticatedRef = useRef(false); + const [currentUrl, setCurrentUrl] = useState(serverUrl); + + useEffect(() => { + // Listen for messages from iframe + const handleMessage = async (event: MessageEvent) => { + // Only accept messages from our configured server + try { + const serverOrigin = new URL(serverUrl).origin; + if (event.origin !== serverOrigin) { + return; + } + + if (event.data && typeof event.data === "object") { + const data = event.data; + + if ( + data.type === "AUTH_SUCCESS" && + data.token && + !hasAuthenticatedRef.current && + !isAuthenticating + ) { + console.log( + "[ElectronLoginForm] Received auth success from iframe", + ); + hasAuthenticatedRef.current = true; + setIsAuthenticating(true); + + try { + // Save JWT to localStorage (Electron mode) + localStorage.setItem("jwt", data.token); + + // Verify it was saved + const savedToken = localStorage.getItem("jwt"); + if (!savedToken) { + throw new Error("Failed to save JWT to localStorage"); + } + + console.log("[ElectronLoginForm] JWT saved successfully"); + + // Small delay to ensure everything is saved + await new Promise((resolve) => setTimeout(resolve, 200)); + + onAuthSuccess(); + } catch (err) { + console.error("[ElectronLoginForm] Error saving JWT:", err); + setError(t("errors.authTokenSaveFailed")); + setIsAuthenticating(false); + hasAuthenticatedRef.current = false; + } + } + } + } catch (err) { + console.error("[ElectronLoginForm] Error processing message:", err); + } + }; + + window.addEventListener("message", handleMessage); + + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [serverUrl, isAuthenticating, onAuthSuccess, t]); + + useEffect(() => { + // Inject script into iframe when it loads + const iframe = iframeRef.current; + if (!iframe) return; + + const handleLoad = () => { + setLoading(false); + + // Update current URL when iframe loads + try { + if (iframe.contentWindow) { + setCurrentUrl(iframe.contentWindow.location.href); + } + } catch (e) { + // Cross-origin, can't access - use serverUrl + setCurrentUrl(serverUrl); + } + + try { + // Inject JavaScript to detect JWT + const injectedScript = ` + (function() { + console.log('[Electron WebView] Script injected'); + + let hasNotified = false; + + function postJWTToParent(token, source) { + if (hasNotified) return; + hasNotified = true; + + console.log('[Electron WebView] Posting JWT to parent, source:', source); + + try { + window.parent.postMessage({ + type: 'AUTH_SUCCESS', + token: token, + source: source, + platform: 'desktop', + timestamp: Date.now() + }, '*'); + } catch (e) { + console.error('[Electron WebView] Error posting message:', e); + } + } + + function checkAuth() { + try { + const localToken = localStorage.getItem('jwt'); + if (localToken && localToken.length > 20) { + postJWTToParent(localToken, 'localStorage'); + return true; + } + + const sessionToken = sessionStorage.getItem('jwt'); + if (sessionToken && sessionToken.length > 20) { + postJWTToParent(sessionToken, 'sessionStorage'); + return true; + } + + const cookies = document.cookie; + if (cookies && cookies.length > 0) { + const cookieArray = cookies.split('; '); + const tokenCookie = cookieArray.find(row => row.startsWith('jwt=')); + + if (tokenCookie) { + const token = tokenCookie.split('=')[1]; + if (token && token.length > 20) { + postJWTToParent(token, 'cookie'); + return true; + } + } + } + } catch (error) { + console.error('[Electron WebView] Error in checkAuth:', error); + } + return false; + } + + // Intercept localStorage.setItem + const originalSetItem = localStorage.setItem; + localStorage.setItem = function(key, value) { + originalSetItem.apply(this, arguments); + if (key === 'jwt' && value && value.length > 20 && !hasNotified) { + setTimeout(() => checkAuth(), 100); + } + }; + + // Intercept sessionStorage.setItem + const originalSessionSetItem = sessionStorage.setItem; + sessionStorage.setItem = function(key, value) { + originalSessionSetItem.apply(this, arguments); + if (key === 'jwt' && value && value.length > 20 && !hasNotified) { + setTimeout(() => checkAuth(), 100); + } + }; + + // Poll for JWT + const intervalId = setInterval(() => { + if (hasNotified) { + clearInterval(intervalId); + return; + } + if (checkAuth()) { + clearInterval(intervalId); + } + }, 500); + + // Stop after 5 minutes + setTimeout(() => { + clearInterval(intervalId); + }, 300000); + + // Initial check + checkAuth(); + })(); + `; + + // Try to inject the script + try { + if (iframe.contentWindow) { + iframe.contentWindow.postMessage( + { type: "INJECT_SCRIPT", script: injectedScript }, + "*", + ); + + // Also try direct execution if same origin + iframe.contentWindow.eval(injectedScript); + } + } catch (err) { + // Cross-origin restrictions - this is expected for external servers + console.warn( + "[ElectronLoginForm] Cannot inject script due to cross-origin restrictions", + ); + } + } catch (err) { + console.error("[ElectronLoginForm] Error in handleLoad:", err); + } + }; + + const handleError = () => { + setLoading(false); + setError(t("errors.failedToLoadServer")); + }; + + iframe.addEventListener("load", handleLoad); + iframe.addEventListener("error", handleError); + + return () => { + iframe.removeEventListener("load", handleLoad); + iframe.removeEventListener("error", handleError); + }; + }, [t]); + + const handleRefresh = () => { + if (iframeRef.current) { + iframeRef.current.src = serverUrl; + setLoading(true); + setError(null); + } + }; + + const handleBack = () => { + onChangeServer(); + }; + + // Format URL for display (remove protocol) + const displayUrl = currentUrl.replace(/^https?:\/\//, ""); + + return ( +
+ {/* Navigation Bar */} +
+ +
+ + {displayUrl} + +
+ +
+ + {error && ( +
+ + + {t("common.error")} + {error} + +
+ )} + + {loading && ( +
+
+ + + {t("auth.loadingServer")} + +
+
+ )} + + {isAuthenticating && ( +
+
+ + + {t("auth.authenticating")} + +
+
+ )} + + {/* Iframe Container */} +
+