feat: remove sessions after reboot
This commit is contained in:
@@ -40,6 +40,20 @@ async function initializeDatabaseAsync(): Promise<void> {
|
|||||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||||
|
|
||||||
memoryDatabase = new Database(decryptedBuffer);
|
memoryDatabase = new Database(decryptedBuffer);
|
||||||
|
|
||||||
|
// Count sessions after loading
|
||||||
|
try {
|
||||||
|
const sessionCount = memoryDatabase
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||||
|
.get() as { count: number };
|
||||||
|
databaseLogger.info("Database loaded from encrypted file", {
|
||||||
|
operation: "db_load",
|
||||||
|
sessionCount: sessionCount.count,
|
||||||
|
bufferSize: decryptedBuffer.length,
|
||||||
|
});
|
||||||
|
} catch (countError) {
|
||||||
|
// Ignore count errors
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const migration = new DatabaseMigration(dataDir);
|
const migration = new DatabaseMigration(dataDir);
|
||||||
const migrationStatus = migration.checkMigrationStatus();
|
const migrationStatus = migration.checkMigrationStatus();
|
||||||
@@ -281,6 +295,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("DELETE FROM sessions").run();
|
||||||
|
databaseLogger.info("All sessions cleared on startup", {
|
||||||
|
operation: "db_init_session_cleanup",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
databaseLogger.warn("Could not clear sessions on startup", {
|
||||||
|
operation: "db_init_session_cleanup_failed",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -473,6 +499,20 @@ async function saveMemoryDatabaseToFile() {
|
|||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count sessions before saving
|
||||||
|
try {
|
||||||
|
const sessionCount = memoryDatabase
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||||
|
.get() as { count: number };
|
||||||
|
databaseLogger.info("Saving database to file", {
|
||||||
|
operation: "db_save",
|
||||||
|
sessionCount: sessionCount.count,
|
||||||
|
bufferSize: buffer.length,
|
||||||
|
});
|
||||||
|
} catch (countError) {
|
||||||
|
// Ignore count errors
|
||||||
|
}
|
||||||
|
|
||||||
if (enableFileEncryption) {
|
if (enableFileEncryption) {
|
||||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||||
buffer,
|
buffer,
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ class AuthManager {
|
|||||||
|
|
||||||
// Calculate expiration timestamp
|
// Calculate expiration timestamp
|
||||||
const expirationMs = this.parseExpiresIn(expiresIn);
|
const expirationMs = this.parseExpiresIn(expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expirationMs).toISOString();
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
||||||
|
const createdAt = now.toISOString();
|
||||||
|
|
||||||
// Store session in database
|
// Store session in database
|
||||||
try {
|
try {
|
||||||
@@ -201,7 +203,9 @@ class AuthManager {
|
|||||||
jwtToken: token,
|
jwtToken: token,
|
||||||
deviceType: options.deviceType,
|
deviceType: options.deviceType,
|
||||||
deviceInfo: options.deviceInfo,
|
deviceInfo: options.deviceInfo,
|
||||||
|
createdAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
lastActiveAt: createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
databaseLogger.info("Session created", {
|
databaseLogger.info("Session created", {
|
||||||
@@ -211,6 +215,30 @@ class AuthManager {
|
|||||||
deviceType: options.deviceType,
|
deviceType: options.deviceType,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Immediately save database to disk to ensure session persists across restarts
|
||||||
|
try {
|
||||||
|
const { saveMemoryDatabaseToFile } = await import(
|
||||||
|
"../database/db/index.js"
|
||||||
|
);
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
databaseLogger.info(
|
||||||
|
"Database saved immediately after session creation",
|
||||||
|
{
|
||||||
|
operation: "session_create_db_save",
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (saveError) {
|
||||||
|
databaseLogger.error(
|
||||||
|
"Failed to save database after session creation",
|
||||||
|
saveError,
|
||||||
|
{
|
||||||
|
operation: "session_create_db_save_failed",
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to create session", error, {
|
databaseLogger.error("Failed to create session", error, {
|
||||||
operation: "session_create_failed",
|
operation: "session_create_failed",
|
||||||
@@ -253,8 +281,25 @@ class AuthManager {
|
|||||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||||
try {
|
try {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
|
|
||||||
|
databaseLogger.info("Attempting JWT verification", {
|
||||||
|
operation: "jwt_verify_attempt",
|
||||||
|
tokenLength: token.length,
|
||||||
|
secretLength: jwtSecret.length,
|
||||||
|
});
|
||||||
|
|
||||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||||
|
|
||||||
|
databaseLogger.info("JWT signature verified successfully", {
|
||||||
|
operation: "jwt_signature_verified",
|
||||||
|
userId: payload.userId,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
hasExpiration: !!payload.exp,
|
||||||
|
expiresAt: payload.exp
|
||||||
|
? new Date(payload.exp * 1000).toISOString()
|
||||||
|
: "N/A",
|
||||||
|
});
|
||||||
|
|
||||||
// For tokens with sessionId, verify the session exists in database
|
// For tokens with sessionId, verify the session exists in database
|
||||||
// This ensures revoked sessions are rejected even after backend restart
|
// This ensures revoked sessions are rejected even after backend restart
|
||||||
if (payload.sessionId) {
|
if (payload.sessionId) {
|
||||||
@@ -272,10 +317,18 @@ class AuthManager {
|
|||||||
operation: "jwt_verify_failed",
|
operation: "jwt_verify_failed",
|
||||||
reason: "session_not_found",
|
reason: "session_not_found",
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
|
userId: payload.userId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
databaseLogger.info("Session found in database", {
|
||||||
|
operation: "jwt_session_found",
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
userId: payload.userId,
|
||||||
|
sessionExpiresAt: sessionRecords[0].expiresAt,
|
||||||
|
});
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
"Failed to check session in database during JWT verification",
|
"Failed to check session in database during JWT verification",
|
||||||
|
|||||||
@@ -23,24 +23,55 @@ class SystemCrypto {
|
|||||||
const envSecret = process.env.JWT_SECRET;
|
const envSecret = process.env.JWT_SECRET;
|
||||||
if (envSecret && envSecret.length >= 64) {
|
if (envSecret && envSecret.length >= 64) {
|
||||||
this.jwtSecret = envSecret;
|
this.jwtSecret = envSecret;
|
||||||
|
databaseLogger.info("JWT secret loaded from environment variable", {
|
||||||
|
operation: "jwt_init_from_env",
|
||||||
|
secretLength: envSecret.length,
|
||||||
|
secretPrefix: envSecret.substring(0, 8) + "...",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const envPath = path.join(dataDir, ".env");
|
const envPath = path.join(dataDir, ".env");
|
||||||
|
|
||||||
|
databaseLogger.info("Attempting to load JWT secret from .env file", {
|
||||||
|
operation: "jwt_init_from_file",
|
||||||
|
envPath,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const envContent = await fs.readFile(envPath, "utf8");
|
const envContent = await fs.readFile(envPath, "utf8");
|
||||||
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
|
const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m);
|
||||||
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
|
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
|
||||||
this.jwtSecret = jwtMatch[1];
|
this.jwtSecret = jwtMatch[1];
|
||||||
process.env.JWT_SECRET = jwtMatch[1];
|
process.env.JWT_SECRET = jwtMatch[1];
|
||||||
|
databaseLogger.success("JWT secret loaded from .env file", {
|
||||||
|
operation: "jwt_init_from_file_success",
|
||||||
|
secretLength: jwtMatch[1].length,
|
||||||
|
secretPrefix: jwtMatch[1].substring(0, 8) + "...",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
databaseLogger.warn(
|
||||||
|
"JWT_SECRET in .env file is invalid or too short",
|
||||||
|
{
|
||||||
|
operation: "jwt_init_invalid_secret",
|
||||||
|
hasMatch: !!jwtMatch,
|
||||||
|
secretLength: jwtMatch?.[1]?.length || 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (fileError) {
|
||||||
// Ignore file read errors, will generate new secret
|
databaseLogger.warn("Failed to read .env file for JWT secret", {
|
||||||
|
operation: "jwt_init_file_read_failed",
|
||||||
|
error:
|
||||||
|
fileError instanceof Error ? fileError.message : "Unknown error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
databaseLogger.warn("Generating new JWT secret", {
|
||||||
|
operation: "jwt_generating_new_secret",
|
||||||
|
});
|
||||||
await this.generateAndGuideUser();
|
await this.generateAndGuideUser();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||||
|
|||||||
@@ -90,7 +90,26 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
|||||||
let os = "Unknown OS";
|
let os = "Unknown OS";
|
||||||
let version = "Unknown";
|
let version = "Unknown";
|
||||||
|
|
||||||
// Detect mobile OS
|
// Check for Termix-Mobile/Platform format first (e.g., "Termix-Mobile/Android" or "Termix-Mobile/iOS")
|
||||||
|
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
|
||||||
|
if (termixPlatformMatch) {
|
||||||
|
const platform = termixPlatformMatch[1];
|
||||||
|
if (platform.toLowerCase() === "android") {
|
||||||
|
// Try to get Android version from full UA string
|
||||||
|
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||||
|
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||||
|
} else if (platform.toLowerCase() === "ios") {
|
||||||
|
// Try to get iOS version from full UA string
|
||||||
|
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
||||||
|
if (iosMatch) {
|
||||||
|
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
||||||
|
os = `iOS ${iosVersion}`;
|
||||||
|
} else {
|
||||||
|
os = "iOS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Check for standard Android/iOS patterns in the user agent
|
||||||
if (userAgent.includes("Android")) {
|
if (userAgent.includes("Android")) {
|
||||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||||
@@ -107,9 +126,13 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
|||||||
os = "iOS";
|
os = "iOS";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to extract app version (if included in UA)
|
// Try to extract app version (if included in UA)
|
||||||
const versionMatch = userAgent.match(/Termix-Mobile\/([\d.]+)/);
|
// Match patterns like "Termix-Mobile/1.0.0" or just "Termix-Mobile"
|
||||||
|
const versionMatch = userAgent.match(
|
||||||
|
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
|
||||||
|
);
|
||||||
if (versionMatch) {
|
if (versionMatch) {
|
||||||
version = versionMatch[1];
|
version = versionMatch[1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ function RootApp() {
|
|||||||
const width = useWindowWidth();
|
const width = useWindowWidth();
|
||||||
const isMobile = width < 768;
|
const isMobile = width < 768;
|
||||||
|
|
||||||
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
const userAgent =
|
||||||
|
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
||||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ export function Auth({
|
|||||||
...props
|
...props
|
||||||
}: AuthProps) {
|
}: AuthProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Detect if we're running in Electron's WebView/iframe
|
||||||
|
const isInElectronWebView = () => {
|
||||||
|
try {
|
||||||
|
// Check if we're in an iframe AND the parent is Electron
|
||||||
|
if (window.self !== window.top) {
|
||||||
|
// We're in an iframe, likely Electron's ElectronLoginForm
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Cross-origin iframe, can't access parent
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
|
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
|
||||||
"login",
|
"login",
|
||||||
);
|
);
|
||||||
@@ -92,12 +108,22 @@ export function Auth({
|
|||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip when in Electron WebView iframe
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
getRegistrationAllowed().then((res) => {
|
getRegistrationAllowed().then((res) => {
|
||||||
setRegistrationAllowed(res.allowed);
|
setRegistrationAllowed(res.allowed);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip when in Electron WebView iframe
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
getPasswordLoginAllowed()
|
getPasswordLoginAllowed()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setPasswordLoginAllowed(res.allowed);
|
setPasswordLoginAllowed(res.allowed);
|
||||||
@@ -110,6 +136,11 @@ export function Auth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip when in Electron WebView iframe
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
getOIDCConfig()
|
getOIDCConfig()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -128,6 +159,14 @@ export function Auth({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip database health check when in Electron WebView iframe
|
||||||
|
// The parent Electron window will handle authentication
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
setDbHealthChecking(false);
|
||||||
|
setDbConnectionFailed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDbHealthChecking(true);
|
setDbHealthChecking(true);
|
||||||
getSetupRequired()
|
getSetupRequired()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -691,21 +730,6 @@ export function Auth({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if we're running in Electron's WebView/iframe
|
|
||||||
const isInElectronWebView = () => {
|
|
||||||
try {
|
|
||||||
// Check if we're in an iframe AND the parent is Electron
|
|
||||||
if (window.self !== window.top) {
|
|
||||||
// We're in an iframe, likely Electron's ElectronLoginForm
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Cross-origin iframe, can't access parent
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ function createApiInstance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined" && (window as any).ReactNativeWebView) {
|
if (typeof window !== "undefined" && (window as any).ReactNativeWebView) {
|
||||||
config.headers["User-Agent"] = "Termix-Mobile";
|
// Try to detect platform from navigator
|
||||||
|
let platform = "Unknown";
|
||||||
|
if (typeof navigator !== "undefined" && navigator.userAgent) {
|
||||||
|
if (navigator.userAgent.includes("Android")) {
|
||||||
|
platform = "Android";
|
||||||
|
} else if (
|
||||||
|
navigator.userAgent.includes("iPhone") ||
|
||||||
|
navigator.userAgent.includes("iPad") ||
|
||||||
|
navigator.userAgent.includes("iOS")
|
||||||
|
) {
|
||||||
|
platform = "iOS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
Reference in New Issue
Block a user