diff --git a/package-lock.json b/package-lock.json index 118022d2..37701105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.9", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", @@ -49,6 +50,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", @@ -4625,6 +4627,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -7045,6 +7056,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", diff --git a/package.json b/package.json index c67397c9..393cfc3f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.9", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", @@ -67,6 +68,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", @@ -128,4 +130,4 @@ "typescript-eslint": "^8.40.0", "vite": "^7.1.5" } -} \ No newline at end of file +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 8ed95be0..70eaa015 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1,6 +1,7 @@ import express from "express"; import bodyParser from "body-parser"; import multer from "multer"; +import cookieParser from "cookie-parser"; import userRoutes from "./routes/users.js"; import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; @@ -34,7 +35,33 @@ const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); app.use( cors({ - origin: "*", + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + // Allow localhost and 127.0.0.1 for development + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000" + ]; + + if (origin.startsWith("https://")) { + return callback(null, true); + } + + if (origin.startsWith("http://")) { + return callback(null, true); + } + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + // Reject other origins + callback(new Error("Not allowed by CORS")); + }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ @@ -179,6 +206,7 @@ async function fetchGitHubAPI( } app.use(bodyParser.json()); +app.use(cookieParser()); app.get("/health", (req, res) => { res.json({ status: "ok" }); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 9beb3b50..60970672 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -787,9 +787,10 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set("success", "true"); - redirectUrl.searchParams.set("token", token); - res.redirect(redirectUrl.toString()); + return res + .cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000)) + .redirect(redirectUrl.toString()); } catch (err) { authLogger.error("OIDC callback failed", err); @@ -919,17 +920,45 @@ router.post("/login", async (req, res) => { dataUnlocked: true, }); - return res.json({ - token, - is_admin: !!userRecord.is_admin, - username: userRecord.username, - }); + return res + .cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000)) + .json({ + success: true, + is_admin: !!userRecord.is_admin, + username: userRecord.username, + }); } catch (err) { authLogger.error("Failed to log in user", err); return res.status(500).json({ error: "Login failed" }); } }); +// Route: Logout user +// POST /users/logout +router.post("/logout", async (req, res) => { + try { + // Try to get userId from JWT if available + const userId = (req as any).userId; + + if (userId) { + // User is authenticated - clear data session + authManager.logoutUser(userId); + authLogger.info("User logged out", { + operation: "user_logout", + userId, + }); + } + + // Always clear the JWT cookie + return res + .clearCookie("jwt", authManager.getSecureCookieOptions(req)) + .json({ success: true, message: "Logged out successfully" }); + } catch (err) { + authLogger.error("Logout failed", err); + return res.status(500).json({ error: "Logout failed" }); + } +}); + // Route: Get current user's info using JWT // GET /users/me router.get("/me", authenticateJWT, async (req: Request, res: Response) => { @@ -1525,11 +1554,13 @@ router.post("/totp/verify-login", async (req, res) => { expiresIn: "50d", }); - return res.json({ - token, - is_admin: !!userRecord.is_admin, - username: userRecord.username, - }); + return res + .cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000)) + .json({ + success: true, + is_admin: !!userRecord.is_admin, + username: userRecord.username, + }); } catch (err) { authLogger.error("TOTP verification failed", err); return res.status(500).json({ error: "TOTP verification failed" }); @@ -1895,26 +1926,7 @@ router.get("/data-status", authenticateJWT, async (req, res) => { } }); -// Route: User logout (clear data session) -// POST /users/logout -router.post("/logout", authenticateJWT, async (req, res) => { - const userId = (req as any).userId; - - try { - authManager.logoutUser(userId); - authLogger.info("User logged out", { - operation: "user_logout", - userId, - }); - res.json({ message: "Logged out successfully" }); - } catch (err) { - authLogger.error("Logout failed", err, { - operation: "logout_error", - userId, - }); - res.status(500).json({ error: "Logout failed" }); - } -}); +// Duplicate logout route removed - handled by the main logout route above // Route: Change user password (re-encrypt data keys) // POST /users/change-password @@ -2259,12 +2271,14 @@ router.post("/recovery/login", async (req, res) => { userId: sessionData.userId, }); - res.json({ - token, - is_admin: !!user[0].is_admin, - username: user[0].username, - message: "Login successful via recovery" - }); + return res + .cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000)) + .json({ + success: true, + is_admin: !!user[0].is_admin, + username: user[0].username, + message: "Login successful via recovery" + }); } catch (error) { authLogger.error("Recovery login failed", error, { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index ce65f4f0..16767332 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1,5 +1,6 @@ import express from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; @@ -49,7 +50,37 @@ const app = express(); app.use( cors({ - origin: "*", + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + // Allow localhost and 127.0.0.1 for development + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000" + ]; + + // Allow any HTTPS origin (production deployments) + if (origin.startsWith("https://")) { + return callback(null, true); + } + + // Allow any HTTP origin for self-hosted scenarios + if (origin.startsWith("http://")) { + return callback(null, true); + } + + // Check against allowed development origins + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + // Reject other origins + callback(new Error("Not allowed by CORS")); + }, + credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", @@ -59,6 +90,7 @@ app.use( ], }), ); +app.use(cookieParser()); app.use(express.json({ limit: "1gb" })); app.use(express.urlencoded({ limit: "1gb", extended: true })); app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 4b3e8879..b2aa0709 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -1,6 +1,7 @@ import express from "express"; import net from "net"; import cors from "cors"; +import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; @@ -278,7 +279,37 @@ function validateHostId( const app = express(); app.use( cors({ - origin: "*", + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + // Allow localhost and 127.0.0.1 for development + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000" + ]; + + // Allow any HTTPS origin (production deployments) + if (origin.startsWith("https://")) { + return callback(null, true); + } + + // Allow any HTTP origin for self-hosted scenarios + if (origin.startsWith("http://")) { + return callback(null, true); + } + + // Check against allowed development origins + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + // Reject other origins + callback(new Error("Not allowed by CORS")); + }, + credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", @@ -288,21 +319,7 @@ app.use( ], }), ); -app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, User-Agent, X-Electron-App", - ); - res.header( - "Access-Control-Allow-Methods", - "GET, POST, PUT, PATCH, DELETE, OPTIONS", - ); - if (req.method === "OPTIONS") { - return res.sendStatus(204); - } - next(); -}); +app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); // Add authentication middleware - Linus principle: eliminate special cases diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 95297d33..009bcb0f 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1,5 +1,6 @@ import express from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; import { Client } from "ssh2"; import { ChildProcess } from "child_process"; import axios from "axios"; @@ -20,7 +21,37 @@ import { SystemCrypto } from "../utils/system-crypto.js"; const app = express(); app.use( cors({ - origin: "*", + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + // Allow localhost and 127.0.0.1 for development + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000" + ]; + + // Allow any HTTPS origin (production deployments) + if (origin.startsWith("https://")) { + return callback(null, true); + } + + // Allow any HTTP origin for self-hosted scenarios + if (origin.startsWith("http://")) { + return callback(null, true); + } + + // Check against allowed development origins + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + // Reject other origins + callback(new Error("Not allowed by CORS")); + }, + credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Origin", @@ -33,6 +64,7 @@ app.use( ], }), ); +app.use(cookieParser()); app.use(express.json()); const activeTunnels = new Map(); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 8c204ef0..624ac45d 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -207,17 +207,39 @@ class AuthManager { }); } + /** + * Helper function to get secure cookie options based on request + */ + getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) { + return { + httpOnly: true, // Prevent XSS attacks + secure: req.secure || req.headers['x-forwarded-proto'] === 'https', // Detect HTTPS properly + sameSite: "strict" as const, // Prevent CSRF attacks + maxAge: maxAge, // Session duration in milliseconds + path: "/", // Available site-wide + }; + } + /** * Authentication middleware */ createAuthMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { - const authHeader = req.headers["authorization"]; - if (!authHeader?.startsWith("Bearer ")) { - return res.status(401).json({ error: "Missing Authorization header" }); + // Try to get JWT from secure HttpOnly cookie first + let token = req.cookies?.jwt; + + // Fallback to Authorization header for backward compatibility + if (!token) { + const authHeader = req.headers["authorization"]; + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.split(" ")[1]; + } + } + + if (!token) { + return res.status(401).json({ error: "Missing authentication token" }); } - const token = authHeader.split(" ")[1]; const payload = await this.verifyJWTToken(token); if (!payload) { diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index d4d9645b..2c5b19bc 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -103,8 +103,8 @@ export function AdminSettings({ const [importPassword, setImportPassword] = React.useState(""); React.useEffect(() => { - const jwt = getCookie("jwt"); - if (!jwt) return; + // JWT is now automatically sent via HttpOnly cookies + // No need to check for JWT cookie manually if (isElectron()) { const serverUrl = (window as any).configuredServerUrl; @@ -147,8 +147,8 @@ export function AdminSettings({ }, []); const fetchUsers = async () => { - const jwt = getCookie("jwt"); - if (!jwt) return; + // JWT is now automatically sent via HttpOnly cookies + // No need to check for JWT cookie manually if (isElectron()) { const serverUrl = (window as any).configuredServerUrl; @@ -172,7 +172,7 @@ export function AdminSettings({ const handleToggleRegistration = async (checked: boolean) => { setRegLoading(true); - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies try { await updateRegistrationAllowed(checked); setAllowRegistration(checked); @@ -204,7 +204,7 @@ export function AdminSettings({ return; } - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies try { await updateOIDCConfig(oidcConfig); toast.success(t("admin.oidcConfigurationUpdated")); @@ -226,7 +226,7 @@ export function AdminSettings({ if (!newAdminUsername.trim()) return; setMakeAdminLoading(true); setMakeAdminError(null); - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies try { await makeUserAdmin(newAdminUsername.trim()); toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername })); @@ -243,7 +243,7 @@ export function AdminSettings({ const handleRemoveAdminStatus = async (username: string) => { confirmWithToast(t("admin.removeAdminStatus", { username }), async () => { - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies try { await removeAdminStatus(username); toast.success(t("admin.adminStatusRemoved", { username })); @@ -258,7 +258,7 @@ export function AdminSettings({ confirmWithToast( t("admin.deleteUser", { username }), async () => { - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies try { await deleteUser(username); toast.success(t("admin.userDeletedSuccessfully", { username })); @@ -292,7 +292,7 @@ export function AdminSettings({ setExportLoading(true); try { - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies const apiUrl = isElectron() ? `${(window as any).configuredServerUrl}/database/export` : "http://localhost:30001/database/export"; @@ -300,9 +300,9 @@ export function AdminSettings({ const response = await fetch(apiUrl, { method: "POST", headers: { - Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, + credentials: "include", // Include HttpOnly cookies body: JSON.stringify({ password: exportPassword }), }); @@ -352,7 +352,7 @@ export function AdminSettings({ setImportLoading(true); try { - const jwt = getCookie("jwt"); + // JWT is now automatically sent via HttpOnly cookies const apiUrl = isElectron() ? `${(window as any).configuredServerUrl}/database/import` : "http://localhost:30001/database/import"; @@ -364,9 +364,7 @@ export function AdminSettings({ const response = await fetch(apiUrl, { method: "POST", - headers: { - Authorization: `Bearer ${jwt}`, - }, + credentials: "include", // Include HttpOnly cookies body: formData, }); diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index b8458e82..d67f283f 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -27,46 +27,36 @@ function AppContent() { useEffect(() => { const checkAuth = () => { - const jwt = getCookie("jwt"); - if (jwt) { - setAuthLoading(true); - getUserInfo() - .then((meRes) => { - setIsAuthenticated(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - - // Check if user data is unlocked - if (!meRes.data_unlocked) { - // Data is locked - user needs to re-authenticate - console.warn("User data is locked - re-authentication required"); - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - } - }) - .catch((err) => { + // With HttpOnly cookies, we can't check for JWT presence from frontend + // Instead, we'll try to get user info and handle the response + setAuthLoading(true); + getUserInfo() + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + + // Check if user data is unlocked + if (!meRes.data_unlocked) { + // Data is locked - user needs to re-authenticate + console.warn("User data is locked - re-authentication required"); setIsAuthenticated(false); setIsAdmin(false); setUsername(null); - - // Check if this is a session expiration error - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { - console.warn("Session expired - please log in again"); - } - - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - }) - .finally(() => setAuthLoading(false)); - } else { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - setAuthLoading(false); - } + } + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + + // Check if this is a session expiration error + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + } + }) + .finally(() => setAuthLoading(false)); }; checkAuth(); diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index fb256ea0..cd5a7d97 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -206,21 +206,13 @@ export function HomepageAuth({ return; } - if (!res || !res.token) { - throw new Error(t("errors.noTokenReceived")); + if (!res || !res.success) { + throw new Error(t("errors.loginFailed")); } - setCookie("jwt", res.token); - - // DEBUG: Verify JWT was set correctly - const verifyJWT = getCookie("jwt"); - console.log("JWT Set Debug:", { - originalToken: res.token.substring(0, 20) + "...", - retrievedToken: verifyJWT ? verifyJWT.substring(0, 20) + "..." : null, - match: res.token === verifyJWT, - tokenLength: res.token.length, - retrievedLength: verifyJWT?.length || 0 - }); + // JWT token is now automatically set as HttpOnly cookie by backend + // No need to manually manage the token on frontend + console.log("Login successful - JWT set as secure HttpOnly cookie"); [meRes] = await Promise.all([getUserInfo()]); @@ -254,7 +246,7 @@ export function HomepageAuth({ setIsAdmin(false); setUsername(null); setUserId(null); - setCookie("jwt", "", -1); + // HttpOnly cookies cannot be cleared from JavaScript - backend handles this if (err?.response?.data?.error?.includes("Database")) { setDbConnectionFailed(true); } else { @@ -306,11 +298,8 @@ export function HomepageAuth({ try { const response = await loginWithRecovery(localUsername, recoveryTempToken); - // Auto-login successful - use same cookie mechanism as normal login - setCookie("jwt", response.token); - - // DEBUG: Verify JWT was set correctly (same as normal login) - const verifyJWT = getCookie("jwt"); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("Recovery login successful - JWT set as secure HttpOnly cookie"); setLoggedIn(true); setIsAdmin(response.is_admin); @@ -442,11 +431,12 @@ export function HomepageAuth({ try { const res = await verifyTOTPLogin(totpTempToken, totpCode); - if (!res || !res.token) { - throw new Error(t("errors.noTokenReceived")); + if (!res || !res.success) { + throw new Error(t("errors.loginFailed")); } - setCookie("jwt", res.token); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("TOTP login successful - JWT set as secure HttpOnly cookie"); const meRes = await getUserInfo(); setInternalLoggedIn(true); @@ -515,7 +505,8 @@ export function HomepageAuth({ setOidcLoading(true); setError(null); - setCookie("jwt", token); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("OIDC login successful - JWT set as secure HttpOnly cookie"); getUserInfo() .then((meRes) => { setInternalLoggedIn(true); @@ -543,7 +534,7 @@ export function HomepageAuth({ setIsAdmin(false); setUsername(null); setUserId(null); - setCookie("jwt", "", -1); + // HttpOnly cookies cannot be cleared from JavaScript - backend handles this window.history.replaceState( {}, document.title, @@ -800,7 +791,7 @@ export function HomepageAuth({ )} {!internalLoggedIn && - (!authLoading || !getCookie("jwt")) && + !authLoading && !totpRequired && ( <>
diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx index 0e59c30a..03f7490e 100644 --- a/src/ui/Desktop/Navigation/LeftSidebar.tsx +++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { getCookie, setCookie, isElectron } from "@/ui/main-axios.ts"; +import { getCookie, setCookie, isElectron, logoutUser } from "@/ui/main-axios.ts"; import { Sidebar, @@ -66,14 +66,23 @@ interface SidebarProps { children?: React.ReactNode; } -function handleLogout() { - if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; +async function handleLogout() { + try { + // Call backend logout endpoint to clear HttpOnly cookie and data session + await logoutUser(); + + // Clear any local storage (for Electron) + if (isElectron()) { + localStorage.removeItem("jwt"); + } + + // Reload the page to reset the application state + window.location.reload(); + } catch (error) { + console.error("Logout failed:", error); + // Even if logout fails, reload the page to reset state + window.location.reload(); } - - window.location.reload(); } export function LeftSidebar({ diff --git a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx b/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx index 6a58aa6d..e09467ab 100644 --- a/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx +++ b/src/ui/Mobile/Apps/Navigation/LeftSidebar.tsx @@ -14,7 +14,7 @@ import { ChevronUp, Menu, User2 } from "lucide-react"; import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Separator } from "@/components/ui/separator.tsx"; import { FolderCard } from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.tsx"; -import { getSSHHosts } from "@/ui/main-axios.ts"; +import { getSSHHosts, logoutUser } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input.tsx"; import { @@ -55,9 +55,18 @@ interface LeftSidebarProps { username?: string | null; } -function handleLogout() { - document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - window.location.reload(); +async function handleLogout() { + try { + // Call backend logout endpoint to clear HttpOnly cookie and data session + await logoutUser(); + + // Reload the page to reset the application state + window.location.reload(); + } catch (error) { + console.error("Logout failed:", error); + // Even if logout fails, reload the page to reset state + window.location.reload(); + } } export function LeftSidebar({ diff --git a/src/ui/Mobile/Homepage/HomepageAuth.tsx b/src/ui/Mobile/Homepage/HomepageAuth.tsx index a5f6f3d6..fe48ae83 100644 --- a/src/ui/Mobile/Homepage/HomepageAuth.tsx +++ b/src/ui/Mobile/Homepage/HomepageAuth.tsx @@ -176,11 +176,12 @@ export function HomepageAuth({ return; } - if (!res || !res.token) { - throw new Error(t("errors.noTokenReceived")); + if (!res || !res.success) { + throw new Error(t("errors.loginFailed")); } - setCookie("jwt", res.token); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("Login successful - JWT set as secure HttpOnly cookie"); [meRes] = await Promise.all([getUserInfo()]); setInternalLoggedIn(true); @@ -213,7 +214,7 @@ export function HomepageAuth({ setIsAdmin(false); setUsername(null); setUserId(null); - setCookie("jwt", "", -1); + // HttpOnly cookies cannot be cleared from JavaScript - backend handles this if (err?.response?.data?.error?.includes("Database")) { setDbError(t("errors.databaseConnection")); } else { @@ -321,11 +322,12 @@ export function HomepageAuth({ try { const res = await verifyTOTPLogin(totpTempToken, totpCode); - if (!res || !res.token) { - throw new Error(t("errors.noTokenReceived")); + if (!res || !res.success) { + throw new Error(t("errors.loginFailed")); } - setCookie("jwt", res.token); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("TOTP login successful - JWT set as secure HttpOnly cookie"); const meRes = await getUserInfo(); setInternalLoggedIn(true); @@ -390,11 +392,12 @@ export function HomepageAuth({ return; } - if (success && token) { + if (success) { setOidcLoading(true); setError(null); - setCookie("jwt", token); + // JWT token is now automatically set as HttpOnly cookie by backend + console.log("OIDC login successful - JWT set as secure HttpOnly cookie"); getUserInfo() .then((meRes) => { setInternalLoggedIn(true); @@ -422,7 +425,7 @@ export function HomepageAuth({ setIsAdmin(false); setUsername(null); setUserId(null); - setCookie("jwt", "", -1); + // HttpOnly cookies cannot be cleared from JavaScript - backend handles this window.history.replaceState( {}, document.title, @@ -522,7 +525,7 @@ export function HomepageAuth({ )} {!internalLoggedIn && - (!authLoading || !getCookie("jwt")) && + !authLoading && !totpRequired && ( <>
diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index 068381f1..c3b4e589 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -25,46 +25,36 @@ const AppContent: FC = () => { useEffect(() => { const checkAuth = () => { - const jwt = getCookie("jwt"); - if (jwt) { - setAuthLoading(true); - getUserInfo() - .then((meRes) => { - setIsAuthenticated(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - - // Check if user data is unlocked - if (!meRes.data_unlocked) { - // Data is locked - user needs to re-authenticate - console.warn("User data is locked - re-authentication required"); - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - } - }) - .catch((err) => { + // With HttpOnly cookies, we can't check for JWT presence from frontend + // Instead, we'll try to get user info and handle the response + setAuthLoading(true); + getUserInfo() + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + + // Check if user data is unlocked + if (!meRes.data_unlocked) { + // Data is locked - user needs to re-authenticate + console.warn("User data is locked - re-authentication required"); setIsAuthenticated(false); setIsAdmin(false); setUsername(null); - - // Check if this is a session expiration error - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { - console.warn("Session expired - please log in again"); - } - - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - }) - .finally(() => setAuthLoading(false)); - } else { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - setAuthLoading(false); - } + } + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + + // Check if this is a session expiration error + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + } + }) + .finally(() => setAuthLoading(false)); }; checkAuth(); diff --git a/src/ui/Mobile/Navigation/LeftSidebar.tsx b/src/ui/Mobile/Navigation/LeftSidebar.tsx index 6b413b27..976bbd71 100644 --- a/src/ui/Mobile/Navigation/LeftSidebar.tsx +++ b/src/ui/Mobile/Navigation/LeftSidebar.tsx @@ -15,7 +15,7 @@ import { ChevronUp, Menu, User2 } from "lucide-react"; import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Separator } from "@/components/ui/separator.tsx"; import { FolderCard } from "@/ui/Mobile/Navigation/Hosts/FolderCard.tsx"; -import { getSSHHosts } from "@/ui/main-axios.ts"; +import { getSSHHosts, logoutUser } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input.tsx"; import { @@ -56,9 +56,18 @@ interface LeftSidebarProps { username?: string | null; } -function handleLogout() { - document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - window.location.reload(); +async function handleLogout() { + try { + // Call backend logout endpoint to clear HttpOnly cookie and data session + await logoutUser(); + + // Reload the page to reset the application state + window.location.reload(); + } catch (error) { + console.error("Logout failed:", error); + // Even if logout fails, reload the page to reset state + window.location.reload(); + } } export function LeftSidebar({ diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 115057da..8904164b 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -62,6 +62,9 @@ export type ServerMetrics = { interface AuthResponse { token: string; + success?: boolean; + is_admin?: boolean; + username?: string; } interface UserInfo { @@ -112,6 +115,8 @@ export function setCookie(name: string, value: string, days = 7): void { if (isElectron()) { localStorage.setItem(name, value); } else { + // Note: For secure authentication, cookies should be set by the backend + // This function is kept for backward compatibility with non-auth cookies const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; } @@ -140,6 +145,7 @@ function createApiInstance( baseURL, headers: { "Content-Type": "application/json" }, timeout: 30000, + withCredentials: true, // Required for HttpOnly cookies to be sent cross-origin }); instance.interceptors.request.use((config) => { @@ -149,7 +155,6 @@ function createApiInstance( (config as any).startTime = startTime; (config as any).requestId = requestId; - const token = getCookie("jwt"); const method = config.method?.toUpperCase() || "UNKNOWN"; const url = config.url || "UNKNOWN"; const fullUrl = `${config.baseURL}${url}`; @@ -167,14 +172,8 @@ function createApiInstance( logger.requestStart(method, fullUrl, context); } - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } else if (process.env.NODE_ENV === "development") { - authLogger.warn( - "No JWT token found, request will be unauthenticated", - context, - ); - } + // Note: JWT token is now automatically sent via secure HttpOnly cookies + // No need to manually set Authorization header for cookie-based auth if (isElectron()) { config.headers["X-Electron-App"] = "true"; @@ -276,20 +275,19 @@ function createApiInstance( const errorCode = (error.response?.data as any)?.code; const isSessionExpired = errorCode === "SESSION_EXPIRED"; + // Clear authentication state if (isElectron()) { localStorage.removeItem("jwt"); } else { - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + // For web, the secure HttpOnly cookie will be cleared by the backend + // We can't clear HttpOnly cookies from JavaScript localStorage.removeItem("jwt"); } // If session expired, show notification and reload page if (isSessionExpired && typeof window !== "undefined") { - // Show user-friendly notification console.warn("Session expired - please log in again"); - // Import toast dynamically to avoid circular dependencies import("sonner").then(({ toast }) => { toast.warning("Session expired - please log in again"); }); @@ -1526,12 +1524,28 @@ export async function loginUser( ): Promise { try { const response = await authApi.post("/users/login", { username, password }); - return response.data; + // JWT token is now set as secure HttpOnly cookie by backend + // Return success status and user info + return { + token: "cookie-based", // Placeholder since token is in HttpOnly cookie + success: response.data.success, + is_admin: response.data.is_admin, + username: response.data.username, + }; } catch (error) { handleApiError(error, "login user"); } } +export async function logoutUser(): Promise<{ success: boolean; message: string }> { + try { + const response = await authApi.post("/users/logout"); + return response.data; + } catch (error) { + handleApiError(error, "logout user"); + } +} + export async function getUserInfo(): Promise { try { const response = await authApi.get("/users/me");