Improved JWT security

This commit is contained in:
LukeGus
2025-09-26 23:27:07 -05:00
parent 2cd1cb64a3
commit b0f25a6971
17 changed files with 407 additions and 217 deletions

30
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -49,6 +50,7 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3", "drizzle-orm": "^0.44.3",
@@ -4625,6 +4627,15 @@
"@types/node": "*" "@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": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -7045,6 +7056,25 @@
"node": ">= 0.6" "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": { "node_modules/core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",

View File

@@ -49,6 +49,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -67,6 +68,7 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3", "drizzle-orm": "^0.44.3",

View File

@@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import multer from "multer"; import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js"; import userRoutes from "./routes/users.js";
import sshRoutes from "./routes/ssh.js"; import sshRoutes from "./routes/ssh.js";
import alertRoutes from "./routes/alerts.js"; import alertRoutes from "./routes/alerts.js";
@@ -34,7 +35,33 @@ const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware(); const requireAdmin = authManager.createAdminMiddleware();
app.use( app.use(
cors({ 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, credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [ allowedHeaders: [
@@ -179,6 +206,7 @@ async function fetchGitHubAPI(
} }
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(cookieParser());
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.json({ status: "ok" }); res.json({ status: "ok" });

View File

@@ -787,9 +787,10 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendUrl); const redirectUrl = new URL(frontendUrl);
redirectUrl.searchParams.set("success", "true"); 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) { } catch (err) {
authLogger.error("OIDC callback failed", err); authLogger.error("OIDC callback failed", err);
@@ -919,17 +920,45 @@ router.post("/login", async (req, res) => {
dataUnlocked: true, dataUnlocked: true,
}); });
return res.json({ return res
token, .cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
is_admin: !!userRecord.is_admin, .json({
username: userRecord.username, success: true,
}); is_admin: !!userRecord.is_admin,
username: userRecord.username,
});
} catch (err) { } catch (err) {
authLogger.error("Failed to log in user", err); authLogger.error("Failed to log in user", err);
return res.status(500).json({ error: "Login failed" }); 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 // Route: Get current user's info using JWT
// GET /users/me // GET /users/me
router.get("/me", authenticateJWT, async (req: Request, res: Response) => { router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
@@ -1525,11 +1554,13 @@ router.post("/totp/verify-login", async (req, res) => {
expiresIn: "50d", expiresIn: "50d",
}); });
return res.json({ return res
token, .cookie("jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000))
is_admin: !!userRecord.is_admin, .json({
username: userRecord.username, success: true,
}); is_admin: !!userRecord.is_admin,
username: userRecord.username,
});
} catch (err) { } catch (err) {
authLogger.error("TOTP verification failed", err); authLogger.error("TOTP verification failed", err);
return res.status(500).json({ error: "TOTP verification failed" }); 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) // Duplicate logout route removed - handled by the main logout route above
// 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" });
}
});
// Route: Change user password (re-encrypt data keys) // Route: Change user password (re-encrypt data keys)
// POST /users/change-password // POST /users/change-password
@@ -2259,12 +2271,14 @@ router.post("/recovery/login", async (req, res) => {
userId: sessionData.userId, userId: sessionData.userId,
}); });
res.json({ return res
token, .cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
is_admin: !!user[0].is_admin, .json({
username: user[0].username, success: true,
message: "Login successful via recovery" is_admin: !!user[0].is_admin,
}); username: user[0].username,
message: "Login successful via recovery"
});
} catch (error) { } catch (error) {
authLogger.error("Recovery login failed", error, { authLogger.error("Recovery login failed", error, {

View File

@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import { Client as SSHClient } from "ssh2"; import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js"; import { sshCredentials } from "../database/db/schema.js";
@@ -49,7 +50,37 @@ const app = express();
app.use( app.use(
cors({ 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"], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [ allowedHeaders: [
"Content-Type", "Content-Type",
@@ -59,6 +90,7 @@ app.use(
], ],
}), }),
); );
app.use(cookieParser());
app.use(express.json({ limit: "1gb" })); app.use(express.json({ limit: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true })); app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));

View File

@@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import net from "net"; import net from "net";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2"; import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js"; import { sshData, sshCredentials } from "../database/db/schema.js";
@@ -278,7 +279,37 @@ function validateHostId(
const app = express(); const app = express();
app.use( app.use(
cors({ 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"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [ allowedHeaders: [
"Content-Type", "Content-Type",
@@ -288,21 +319,7 @@ app.use(
], ],
}), }),
); );
app.use((req, res, next) => { app.use(cookieParser());
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(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
// Add authentication middleware - Linus principle: eliminate special cases // Add authentication middleware - Linus principle: eliminate special cases

View File

@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import { Client } from "ssh2"; import { Client } from "ssh2";
import { ChildProcess } from "child_process"; import { ChildProcess } from "child_process";
import axios from "axios"; import axios from "axios";
@@ -20,7 +21,37 @@ import { SystemCrypto } from "../utils/system-crypto.js";
const app = express(); const app = express();
app.use( app.use(
cors({ 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"], methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [ allowedHeaders: [
"Origin", "Origin",
@@ -33,6 +64,7 @@ app.use(
], ],
}), }),
); );
app.use(cookieParser());
app.use(express.json()); app.use(express.json());
const activeTunnels = new Map<string, Client>(); const activeTunnels = new Map<string, Client>();

View File

@@ -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 * Authentication middleware
*/ */
createAuthMiddleware() { createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers["authorization"]; // Try to get JWT from secure HttpOnly cookie first
if (!authHeader?.startsWith("Bearer ")) { let token = req.cookies?.jwt;
return res.status(401).json({ error: "Missing Authorization header" });
// 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); const payload = await this.verifyJWTToken(token);
if (!payload) { if (!payload) {

View File

@@ -103,8 +103,8 @@ export function AdminSettings({
const [importPassword, setImportPassword] = React.useState(""); const [importPassword, setImportPassword] = React.useState("");
React.useEffect(() => { React.useEffect(() => {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
if (!jwt) return; // No need to check for JWT cookie manually
if (isElectron()) { if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl; const serverUrl = (window as any).configuredServerUrl;
@@ -147,8 +147,8 @@ export function AdminSettings({
}, []); }, []);
const fetchUsers = async () => { const fetchUsers = async () => {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
if (!jwt) return; // No need to check for JWT cookie manually
if (isElectron()) { if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl; const serverUrl = (window as any).configuredServerUrl;
@@ -172,7 +172,7 @@ export function AdminSettings({
const handleToggleRegistration = async (checked: boolean) => { const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true); setRegLoading(true);
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
try { try {
await updateRegistrationAllowed(checked); await updateRegistrationAllowed(checked);
setAllowRegistration(checked); setAllowRegistration(checked);
@@ -204,7 +204,7 @@ export function AdminSettings({
return; return;
} }
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
try { try {
await updateOIDCConfig(oidcConfig); await updateOIDCConfig(oidcConfig);
toast.success(t("admin.oidcConfigurationUpdated")); toast.success(t("admin.oidcConfigurationUpdated"));
@@ -226,7 +226,7 @@ export function AdminSettings({
if (!newAdminUsername.trim()) return; if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true); setMakeAdminLoading(true);
setMakeAdminError(null); setMakeAdminError(null);
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
try { try {
await makeUserAdmin(newAdminUsername.trim()); await makeUserAdmin(newAdminUsername.trim());
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername })); toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
@@ -243,7 +243,7 @@ export function AdminSettings({
const handleRemoveAdminStatus = async (username: string) => { const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => { confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
try { try {
await removeAdminStatus(username); await removeAdminStatus(username);
toast.success(t("admin.adminStatusRemoved", { username })); toast.success(t("admin.adminStatusRemoved", { username }));
@@ -258,7 +258,7 @@ export function AdminSettings({
confirmWithToast( confirmWithToast(
t("admin.deleteUser", { username }), t("admin.deleteUser", { username }),
async () => { async () => {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
try { try {
await deleteUser(username); await deleteUser(username);
toast.success(t("admin.userDeletedSuccessfully", { username })); toast.success(t("admin.userDeletedSuccessfully", { username }));
@@ -292,7 +292,7 @@ export function AdminSettings({
setExportLoading(true); setExportLoading(true);
try { try {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
const apiUrl = isElectron() const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/export` ? `${(window as any).configuredServerUrl}/database/export`
: "http://localhost:30001/database/export"; : "http://localhost:30001/database/export";
@@ -300,9 +300,9 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", // Include HttpOnly cookies
body: JSON.stringify({ password: exportPassword }), body: JSON.stringify({ password: exportPassword }),
}); });
@@ -352,7 +352,7 @@ export function AdminSettings({
setImportLoading(true); setImportLoading(true);
try { try {
const jwt = getCookie("jwt"); // JWT is now automatically sent via HttpOnly cookies
const apiUrl = isElectron() const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/import` ? `${(window as any).configuredServerUrl}/database/import`
: "http://localhost:30001/database/import"; : "http://localhost:30001/database/import";
@@ -364,9 +364,7 @@ export function AdminSettings({
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { credentials: "include", // Include HttpOnly cookies
Authorization: `Bearer ${jwt}`,
},
body: formData, body: formData,
}); });

View File

@@ -27,46 +27,36 @@ function AppContent() {
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
const jwt = getCookie("jwt"); // With HttpOnly cookies, we can't check for JWT presence from frontend
if (jwt) { // Instead, we'll try to get user info and handle the response
setAuthLoading(true); setAuthLoading(true);
getUserInfo() getUserInfo()
.then((meRes) => { .then((meRes) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
// Check if user data is unlocked // Check if user data is unlocked
if (!meRes.data_unlocked) { if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate // Data is locked - user needs to re-authenticate
console.warn("User data is locked - re-authentication required"); 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) => {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Check if this is a session expiration error // Check if this is a session expiration error
const errorCode = err?.response?.data?.code; const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") { if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");
} }
})
document.cookie = .finally(() => setAuthLoading(false));
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}; };
checkAuth(); checkAuth();

View File

@@ -206,21 +206,13 @@ export function HomepageAuth({
return; return;
} }
if (!res || !res.token) { if (!res || !res.success) {
throw new Error(t("errors.noTokenReceived")); throw new Error(t("errors.loginFailed"));
} }
setCookie("jwt", res.token); // JWT token is now automatically set as HttpOnly cookie by backend
// No need to manually manage the token on frontend
// DEBUG: Verify JWT was set correctly console.log("Login successful - JWT set as secure HttpOnly cookie");
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
});
[meRes] = await Promise.all([getUserInfo()]); [meRes] = await Promise.all([getUserInfo()]);
@@ -254,7 +246,7 @@ export function HomepageAuth({
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); // HttpOnly cookies cannot be cleared from JavaScript - backend handles this
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbConnectionFailed(true); setDbConnectionFailed(true);
} else { } else {
@@ -306,11 +298,8 @@ export function HomepageAuth({
try { try {
const response = await loginWithRecovery(localUsername, recoveryTempToken); const response = await loginWithRecovery(localUsername, recoveryTempToken);
// Auto-login successful - use same cookie mechanism as normal login // JWT token is now automatically set as HttpOnly cookie by backend
setCookie("jwt", response.token); console.log("Recovery login successful - JWT set as secure HttpOnly cookie");
// DEBUG: Verify JWT was set correctly (same as normal login)
const verifyJWT = getCookie("jwt");
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(response.is_admin); setIsAdmin(response.is_admin);
@@ -442,11 +431,12 @@ export function HomepageAuth({
try { try {
const res = await verifyTOTPLogin(totpTempToken, totpCode); const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) { if (!res || !res.success) {
throw new Error(t("errors.noTokenReceived")); 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(); const meRes = await getUserInfo();
setInternalLoggedIn(true); setInternalLoggedIn(true);
@@ -515,7 +505,8 @@ export function HomepageAuth({
setOidcLoading(true); setOidcLoading(true);
setError(null); 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() getUserInfo()
.then((meRes) => { .then((meRes) => {
setInternalLoggedIn(true); setInternalLoggedIn(true);
@@ -543,7 +534,7 @@ export function HomepageAuth({
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); // HttpOnly cookies cannot be cleared from JavaScript - backend handles this
window.history.replaceState( window.history.replaceState(
{}, {},
document.title, document.title,
@@ -800,7 +791,7 @@ export function HomepageAuth({
)} )}
{!internalLoggedIn && {!internalLoggedIn &&
(!authLoading || !getCookie("jwt")) && !authLoading &&
!totpRequired && ( !totpRequired && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react"; import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next"; 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 { import {
Sidebar, Sidebar,
@@ -66,14 +66,23 @@ interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
function handleLogout() { async function handleLogout() {
if (isElectron()) { try {
localStorage.removeItem("jwt"); // Call backend logout endpoint to clear HttpOnly cookie and data session
} else { await logoutUser();
document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}
window.location.reload(); // 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();
}
} }
export function LeftSidebar({ export function LeftSidebar({

View File

@@ -14,7 +14,7 @@ import { ChevronUp, Menu, User2 } from "lucide-react";
import React, { useState, useEffect, useMemo, useCallback } from "react"; import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { FolderCard } from "@/ui/Mobile/Apps/Navigation/Hosts/FolderCard.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 { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { import {
@@ -55,9 +55,18 @@ interface LeftSidebarProps {
username?: string | null; username?: string | null;
} }
function handleLogout() { async function handleLogout() {
document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; try {
window.location.reload(); // 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({ export function LeftSidebar({

View File

@@ -176,11 +176,12 @@ export function HomepageAuth({
return; return;
} }
if (!res || !res.token) { if (!res || !res.success) {
throw new Error(t("errors.noTokenReceived")); 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()]); [meRes] = await Promise.all([getUserInfo()]);
setInternalLoggedIn(true); setInternalLoggedIn(true);
@@ -213,7 +214,7 @@ export function HomepageAuth({
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); // HttpOnly cookies cannot be cleared from JavaScript - backend handles this
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbError(t("errors.databaseConnection")); setDbError(t("errors.databaseConnection"));
} else { } else {
@@ -321,11 +322,12 @@ export function HomepageAuth({
try { try {
const res = await verifyTOTPLogin(totpTempToken, totpCode); const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) { if (!res || !res.success) {
throw new Error(t("errors.noTokenReceived")); 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(); const meRes = await getUserInfo();
setInternalLoggedIn(true); setInternalLoggedIn(true);
@@ -390,11 +392,12 @@ export function HomepageAuth({
return; return;
} }
if (success && token) { if (success) {
setOidcLoading(true); setOidcLoading(true);
setError(null); 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() getUserInfo()
.then((meRes) => { .then((meRes) => {
setInternalLoggedIn(true); setInternalLoggedIn(true);
@@ -422,7 +425,7 @@ export function HomepageAuth({
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null); setUserId(null);
setCookie("jwt", "", -1); // HttpOnly cookies cannot be cleared from JavaScript - backend handles this
window.history.replaceState( window.history.replaceState(
{}, {},
document.title, document.title,
@@ -522,7 +525,7 @@ export function HomepageAuth({
)} )}
{!internalLoggedIn && {!internalLoggedIn &&
(!authLoading || !getCookie("jwt")) && !authLoading &&
!totpRequired && ( !totpRequired && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">

View File

@@ -25,46 +25,36 @@ const AppContent: FC = () => {
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
const jwt = getCookie("jwt"); // With HttpOnly cookies, we can't check for JWT presence from frontend
if (jwt) { // Instead, we'll try to get user info and handle the response
setAuthLoading(true); setAuthLoading(true);
getUserInfo() getUserInfo()
.then((meRes) => { .then((meRes) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
// Check if user data is unlocked // Check if user data is unlocked
if (!meRes.data_unlocked) { if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate // Data is locked - user needs to re-authenticate
console.warn("User data is locked - re-authentication required"); 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) => {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
// Check if this is a session expiration error // Check if this is a session expiration error
const errorCode = err?.response?.data?.code; const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") { if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");
} }
})
document.cookie = .finally(() => setAuthLoading(false));
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}; };
checkAuth(); checkAuth();

View File

@@ -15,7 +15,7 @@ import { ChevronUp, Menu, User2 } from "lucide-react";
import React, { useState, useEffect, useMemo, useCallback } from "react"; import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { FolderCard } from "@/ui/Mobile/Navigation/Hosts/FolderCard.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 { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { import {
@@ -56,9 +56,18 @@ interface LeftSidebarProps {
username?: string | null; username?: string | null;
} }
function handleLogout() { async function handleLogout() {
document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; try {
window.location.reload(); // 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({ export function LeftSidebar({

View File

@@ -62,6 +62,9 @@ export type ServerMetrics = {
interface AuthResponse { interface AuthResponse {
token: string; token: string;
success?: boolean;
is_admin?: boolean;
username?: string;
} }
interface UserInfo { interface UserInfo {
@@ -112,6 +115,8 @@ export function setCookie(name: string, value: string, days = 7): void {
if (isElectron()) { if (isElectron()) {
localStorage.setItem(name, value); localStorage.setItem(name, value);
} else { } 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(); const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
} }
@@ -140,6 +145,7 @@ function createApiInstance(
baseURL, baseURL,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
timeout: 30000, timeout: 30000,
withCredentials: true, // Required for HttpOnly cookies to be sent cross-origin
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
@@ -149,7 +155,6 @@ function createApiInstance(
(config as any).startTime = startTime; (config as any).startTime = startTime;
(config as any).requestId = requestId; (config as any).requestId = requestId;
const token = getCookie("jwt");
const method = config.method?.toUpperCase() || "UNKNOWN"; const method = config.method?.toUpperCase() || "UNKNOWN";
const url = config.url || "UNKNOWN"; const url = config.url || "UNKNOWN";
const fullUrl = `${config.baseURL}${url}`; const fullUrl = `${config.baseURL}${url}`;
@@ -167,14 +172,8 @@ function createApiInstance(
logger.requestStart(method, fullUrl, context); logger.requestStart(method, fullUrl, context);
} }
if (token) { // Note: JWT token is now automatically sent via secure HttpOnly cookies
config.headers.Authorization = `Bearer ${token}`; // No need to manually set Authorization header for cookie-based auth
} else if (process.env.NODE_ENV === "development") {
authLogger.warn(
"No JWT token found, request will be unauthenticated",
context,
);
}
if (isElectron()) { if (isElectron()) {
config.headers["X-Electron-App"] = "true"; config.headers["X-Electron-App"] = "true";
@@ -276,20 +275,19 @@ function createApiInstance(
const errorCode = (error.response?.data as any)?.code; const errorCode = (error.response?.data as any)?.code;
const isSessionExpired = errorCode === "SESSION_EXPIRED"; const isSessionExpired = errorCode === "SESSION_EXPIRED";
// Clear authentication state
if (isElectron()) { if (isElectron()) {
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
} else { } else {
document.cookie = // For web, the secure HttpOnly cookie will be cleared by the backend
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; // We can't clear HttpOnly cookies from JavaScript
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
} }
// If session expired, show notification and reload page // If session expired, show notification and reload page
if (isSessionExpired && typeof window !== "undefined") { if (isSessionExpired && typeof window !== "undefined") {
// Show user-friendly notification
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");
// Import toast dynamically to avoid circular dependencies
import("sonner").then(({ toast }) => { import("sonner").then(({ toast }) => {
toast.warning("Session expired - please log in again"); toast.warning("Session expired - please log in again");
}); });
@@ -1526,12 +1524,28 @@ export async function loginUser(
): Promise<AuthResponse> { ): Promise<AuthResponse> {
try { try {
const response = await authApi.post("/users/login", { username, password }); 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) { } catch (error) {
handleApiError(error, "login user"); 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<UserInfo> { export async function getUserInfo(): Promise<UserInfo> {
try { try {
const response = await authApi.get("/users/me"); const response = await authApi.get("/users/me");