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",
"@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",

View File

@@ -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"
}
}
}

View File

@@ -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" });

View File

@@ -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, {

View File

@@ -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" }));

View File

@@ -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

View File

@@ -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<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
*/
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) {

View File

@@ -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,
});

View File

@@ -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();

View File

@@ -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 && (
<>
<div className="flex gap-2 mb-6">

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 && (
<>
<div className="flex gap-2 mb-6">

View File

@@ -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();

View File

@@ -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({

View File

@@ -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<AuthResponse> {
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<UserInfo> {
try {
const response = await authApi.get("/users/me");