feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web
This commit is contained in:
@@ -146,6 +146,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
@@ -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<string, unknown> = {
|
||||
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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string> {
|
||||
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<JWTPayload | null> {
|
||||
@@ -175,6 +264,152 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string): Promise<boolean> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<any[]> {
|
||||
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<any[]> {
|
||||
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();
|
||||
|
||||
243
src/backend/utils/user-agent-parser.ts
Normal file
243
src/backend/utils/user-agent-parser.ts
Normal file
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user