* fix: Resolve database encryption atomicity issues and enhance debugging (#430) * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Merge metadata and DB into 1 file * fix: Add initial command palette * Feature/german language support (#431) * Update translation.json Fixed some translation issues for German, made it more user friendly and common. * Update translation.json added updated block for serverStats * Update translation.json Added translations * Update translation.json Removed duplicate of "free":"Free" * feat: Finalize command palette * fix: Several bug fixes for terminals, server stats, and general feature improvements * feat: Enhanced security, UI improvements, and animations (#432) * fix: Remove empty catch blocks and add error logging * refactor: Modularize server stats widget collectors * feat: Add i18n support for terminal customization and login stats - Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages - Add SSH login statistics translations - Update HostManagerEditor to use i18n for all terminal customization UI elements - Update LoginStatsWidget to use i18n for all UI text - Add missing logger imports in backend files for improved debugging * feat: Add keyboard shortcut enhancements with Kbd component - Add shadcn kbd component for displaying keyboard shortcuts - Enhance file manager context menu to display shortcuts with Kbd component - Add 5 new keyboard shortcuts to file manager: - Ctrl+D: Download selected files - Ctrl+N: Create new file - Ctrl+Shift+N: Create new folder - Ctrl+U: Upload files - Enter: Open/run selected file - Add keyboard shortcut hints to command palette footer - Create helper function to parse and render keyboard shortcuts * feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface * feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel * feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI * feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX * fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation * revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes * feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade * feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience * feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience * feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations * feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache * refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display * refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design * feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading * feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color * fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility * security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message * security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits * French translation (#434) * Adding French Language * Enhancements * feat: Replace the old ssh tools system with a new dedicated sidebar * fix: Merge zac/luke * fix: Finalize new sidebar, improve and loading animations * Added ability to close non-primary tabs involved in a split view (#435) * fix: General bug fixes/small feature improvements * feat: General UI improvements and translation updates * fix: Command history and file manager styling issues * feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc * fix: add Accept header for OIDC callback request (#436) * Delete DOWNLOADS.md * fix: add Accept header for OIDC callback request --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: More bug fixes and QOL fixes * fix: Server stats not respecting interval and fixed SSH toool type issues * fix: Remove github links * fix: Delete account spacing * fix: Increment version * fix: Unable to delete hosts and add nginx for terminal * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: OIDC/local account linking breaking both logins * chore: File cleanup * feat: Max terminal tab size and save current file manager sorting type * fix: Terminal display issue, migrate host editor to use combobox * feat: Add snippet folder/customization system * fix: Fix OIDC linking and prep release * fix: Increment version --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Max <herzmaximilian@gmail.com> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: jarrah31 <jarrah31@gmail.com> Co-authored-by: Kf637 <mail@kf637.tech>
786 lines
21 KiB
TypeScript
786 lines
21 KiB
TypeScript
import jwt from "jsonwebtoken";
|
|
import { UserCrypto } from "./user-crypto.js";
|
|
import { SystemCrypto } from "./system-crypto.js";
|
|
import { DataCrypto } from "./data-crypto.js";
|
|
import { databaseLogger } from "./logger.js";
|
|
import type { Request, Response, NextFunction } from "express";
|
|
import { db } from "../database/db/index.js";
|
|
import { sessions } from "../database/db/schema.js";
|
|
import { eq, and, sql } from "drizzle-orm";
|
|
import { nanoid } from "nanoid";
|
|
import type { DeviceType } from "./user-agent-parser.js";
|
|
|
|
interface AuthenticationResult {
|
|
success: boolean;
|
|
token?: string;
|
|
userId?: string;
|
|
isAdmin?: boolean;
|
|
username?: string;
|
|
requiresTOTP?: boolean;
|
|
tempToken?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface JWTPayload {
|
|
userId: string;
|
|
sessionId?: string;
|
|
pendingTOTP?: boolean;
|
|
iat?: number;
|
|
exp?: number;
|
|
}
|
|
|
|
interface AuthenticatedRequest extends Request {
|
|
userId?: string;
|
|
pendingTOTP?: boolean;
|
|
dataKey?: Buffer;
|
|
}
|
|
|
|
interface RequestWithHeaders extends Request {
|
|
headers: Request["headers"] & {
|
|
"x-forwarded-proto"?: string;
|
|
};
|
|
}
|
|
|
|
class AuthManager {
|
|
private static instance: AuthManager;
|
|
private systemCrypto: SystemCrypto;
|
|
private userCrypto: UserCrypto;
|
|
|
|
private constructor() {
|
|
this.systemCrypto = SystemCrypto.getInstance();
|
|
this.userCrypto = UserCrypto.getInstance();
|
|
|
|
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
|
this.invalidateUserTokens(userId);
|
|
});
|
|
|
|
setInterval(
|
|
() => {
|
|
this.cleanupExpiredSessions().catch((error) => {
|
|
databaseLogger.error(
|
|
"Failed to run periodic session cleanup",
|
|
error,
|
|
{
|
|
operation: "session_cleanup_periodic",
|
|
},
|
|
);
|
|
});
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
}
|
|
|
|
static getInstance(): AuthManager {
|
|
if (!this.instance) {
|
|
this.instance = new AuthManager();
|
|
}
|
|
return this.instance;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
await this.systemCrypto.initializeJWTSecret();
|
|
}
|
|
|
|
async registerUser(userId: string, password: string): Promise<void> {
|
|
await this.userCrypto.setupUserEncryption(userId, password);
|
|
}
|
|
|
|
async registerOIDCUser(
|
|
userId: string,
|
|
sessionDurationMs: number,
|
|
): Promise<void> {
|
|
await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs);
|
|
}
|
|
|
|
async authenticateOIDCUser(
|
|
userId: string,
|
|
deviceType?: DeviceType,
|
|
): Promise<boolean> {
|
|
const sessionDurationMs =
|
|
deviceType === "desktop" || deviceType === "mobile"
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: 7 * 24 * 60 * 60 * 1000;
|
|
|
|
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
|
userId,
|
|
sessionDurationMs,
|
|
);
|
|
|
|
if (authenticated) {
|
|
await this.performLazyEncryptionMigration(userId);
|
|
}
|
|
|
|
return authenticated;
|
|
}
|
|
|
|
async authenticateUser(
|
|
userId: string,
|
|
password: string,
|
|
deviceType?: DeviceType,
|
|
): Promise<boolean> {
|
|
const sessionDurationMs =
|
|
deviceType === "desktop" || deviceType === "mobile"
|
|
? 30 * 24 * 60 * 60 * 1000
|
|
: 7 * 24 * 60 * 60 * 1000;
|
|
|
|
const authenticated = await this.userCrypto.authenticateUser(
|
|
userId,
|
|
password,
|
|
sessionDurationMs,
|
|
);
|
|
|
|
if (authenticated) {
|
|
await this.performLazyEncryptionMigration(userId);
|
|
}
|
|
|
|
return authenticated;
|
|
}
|
|
|
|
async convertToOIDCEncryption(userId: string): Promise<void> {
|
|
await this.userCrypto.convertToOIDCEncryption(userId);
|
|
}
|
|
|
|
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
|
try {
|
|
const userDataKey = this.getUserDataKey(userId);
|
|
if (!userDataKey) {
|
|
databaseLogger.warn(
|
|
"Cannot perform lazy encryption migration - user data key not available",
|
|
{
|
|
operation: "lazy_encryption_migration_no_key",
|
|
userId,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
|
|
const sqlite = getSqlite();
|
|
|
|
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
|
|
userId,
|
|
userDataKey,
|
|
sqlite,
|
|
);
|
|
|
|
if (migrationResult.migrated) {
|
|
await saveMemoryDatabaseToFile();
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.error("Lazy encryption migration failed", error, {
|
|
operation: "lazy_encryption_migration_error",
|
|
userId,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
|
|
async generateJWTToken(
|
|
userId: string,
|
|
options: {
|
|
expiresIn?: string;
|
|
pendingTOTP?: boolean;
|
|
deviceType?: DeviceType;
|
|
deviceInfo?: string;
|
|
} = {},
|
|
): Promise<string> {
|
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
|
|
|
let expiresIn = options.expiresIn;
|
|
if (!expiresIn && !options.pendingTOTP) {
|
|
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
|
|
expiresIn = "30d";
|
|
} else {
|
|
expiresIn = "7d";
|
|
}
|
|
} else if (!expiresIn) {
|
|
expiresIn = "7d";
|
|
}
|
|
|
|
const payload: JWTPayload = { userId };
|
|
if (options.pendingTOTP) {
|
|
payload.pendingTOTP = true;
|
|
}
|
|
|
|
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
|
const sessionId = nanoid();
|
|
payload.sessionId = sessionId;
|
|
|
|
const token = jwt.sign(payload, jwtSecret, {
|
|
expiresIn,
|
|
} as jwt.SignOptions);
|
|
|
|
const expirationMs = this.parseExpiresIn(expiresIn);
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
|
const createdAt = now.toISOString();
|
|
|
|
try {
|
|
await db.insert(sessions).values({
|
|
id: sessionId,
|
|
userId,
|
|
jwtToken: token,
|
|
deviceType: options.deviceType,
|
|
deviceInfo: options.deviceInfo,
|
|
createdAt,
|
|
expiresAt,
|
|
lastActiveAt: createdAt,
|
|
});
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
databaseLogger.error(
|
|
"Failed to save database after session creation",
|
|
saveError,
|
|
{
|
|
operation: "session_create_db_save_failed",
|
|
sessionId,
|
|
},
|
|
);
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to create session", error, {
|
|
operation: "session_create_failed",
|
|
userId,
|
|
sessionId,
|
|
});
|
|
}
|
|
|
|
return token;
|
|
}
|
|
|
|
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
|
}
|
|
|
|
private parseExpiresIn(expiresIn: string): number {
|
|
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
|
|
|
const value = parseInt(match[1]);
|
|
const unit = match[2];
|
|
|
|
switch (unit) {
|
|
case "s":
|
|
return value * 1000;
|
|
case "m":
|
|
return value * 60 * 1000;
|
|
case "h":
|
|
return value * 60 * 60 * 1000;
|
|
case "d":
|
|
return value * 24 * 60 * 60 * 1000;
|
|
default:
|
|
return 7 * 24 * 60 * 60 * 1000;
|
|
}
|
|
}
|
|
|
|
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
|
try {
|
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
|
|
|
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
|
|
|
if (payload.sessionId) {
|
|
try {
|
|
const sessionRecords = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.id, payload.sessionId))
|
|
.limit(1);
|
|
|
|
if (sessionRecords.length === 0) {
|
|
databaseLogger.warn("Session not found during JWT verification", {
|
|
operation: "jwt_verify_session_not_found",
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
return null;
|
|
}
|
|
} catch (dbError) {
|
|
databaseLogger.error(
|
|
"Failed to check session in database during JWT verification",
|
|
dbError,
|
|
{
|
|
operation: "jwt_verify_session_check_failed",
|
|
sessionId: payload.sessionId,
|
|
},
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return payload;
|
|
} catch (error) {
|
|
databaseLogger.warn("JWT verification failed", {
|
|
operation: "jwt_verify_failed",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
errorName: error instanceof Error ? error.name : "Unknown",
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
invalidateJWTToken(token: string): void {}
|
|
|
|
invalidateUserTokens(userId: string): void {}
|
|
|
|
async revokeSession(sessionId: string): Promise<boolean> {
|
|
try {
|
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
databaseLogger.error(
|
|
"Failed to save database after session revocation",
|
|
saveError,
|
|
{
|
|
operation: "session_revoke_db_save_failed",
|
|
sessionId,
|
|
},
|
|
);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to delete session", error, {
|
|
operation: "session_delete_failed",
|
|
sessionId,
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async revokeAllUserSessions(
|
|
userId: string,
|
|
exceptSessionId?: string,
|
|
): Promise<number> {
|
|
try {
|
|
const userSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, userId));
|
|
|
|
const deletedCount = userSessions.filter(
|
|
(s) => !exceptSessionId || s.id !== exceptSessionId,
|
|
).length;
|
|
|
|
if (exceptSessionId) {
|
|
await db
|
|
.delete(sessions)
|
|
.where(
|
|
and(
|
|
eq(sessions.userId, userId),
|
|
sql`${sessions.id} != ${exceptSessionId}`,
|
|
),
|
|
);
|
|
} else {
|
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
|
}
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
databaseLogger.error(
|
|
"Failed to save database after revoking all user sessions",
|
|
saveError,
|
|
{
|
|
operation: "user_sessions_revoke_db_save_failed",
|
|
userId,
|
|
},
|
|
);
|
|
}
|
|
|
|
return deletedCount;
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to delete user sessions", error, {
|
|
operation: "user_sessions_delete_failed",
|
|
userId,
|
|
});
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async cleanupExpiredSessions(): Promise<number> {
|
|
try {
|
|
const expiredSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
|
|
|
const expiredCount = expiredSessions.length;
|
|
|
|
if (expiredCount === 0) {
|
|
return 0;
|
|
}
|
|
|
|
await db
|
|
.delete(sessions)
|
|
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
databaseLogger.error(
|
|
"Failed to save database after cleaning up expired sessions",
|
|
saveError,
|
|
{
|
|
operation: "sessions_cleanup_db_save_failed",
|
|
},
|
|
);
|
|
}
|
|
|
|
const affectedUsers = new Set(expiredSessions.map((s) => s.userId));
|
|
for (const userId of affectedUsers) {
|
|
const remainingSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, userId));
|
|
|
|
if (remainingSessions.length === 0) {
|
|
this.userCrypto.logoutUser(userId);
|
|
}
|
|
}
|
|
|
|
return expiredCount;
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to cleanup expired sessions", error, {
|
|
operation: "sessions_cleanup_failed",
|
|
});
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async getAllSessions(): Promise<any[]> {
|
|
try {
|
|
const allSessions = await db.select().from(sessions);
|
|
return allSessions;
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to get all sessions", error, {
|
|
operation: "sessions_get_all_failed",
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getUserSessions(userId: string): Promise<any[]> {
|
|
try {
|
|
const userSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, userId));
|
|
return userSessions;
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to get user sessions", error, {
|
|
operation: "sessions_get_user_failed",
|
|
userId,
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
|
|
getSecureCookieOptions(
|
|
req: RequestWithHeaders,
|
|
maxAge: number = 7 * 24 * 60 * 60 * 1000,
|
|
) {
|
|
return {
|
|
httpOnly: false,
|
|
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
|
sameSite: "strict" as const,
|
|
maxAge: maxAge,
|
|
path: "/",
|
|
};
|
|
}
|
|
|
|
createAuthMiddleware() {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
const authReq = req as AuthenticatedRequest;
|
|
let token = authReq.cookies?.jwt;
|
|
|
|
if (!token) {
|
|
const authHeader = authReq.headers["authorization"];
|
|
if (authHeader?.startsWith("Bearer ")) {
|
|
token = authHeader.split(" ")[1];
|
|
}
|
|
}
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: "Missing authentication token" });
|
|
}
|
|
|
|
const payload = await this.verifyJWTToken(token);
|
|
|
|
if (!payload) {
|
|
return res.status(401).json({ error: "Invalid token" });
|
|
}
|
|
|
|
if (payload.sessionId) {
|
|
try {
|
|
const sessionRecords = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.id, payload.sessionId))
|
|
.limit(1);
|
|
|
|
if (sessionRecords.length === 0) {
|
|
databaseLogger.warn("Session not found in middleware", {
|
|
operation: "middleware_session_not_found",
|
|
sessionId: payload.sessionId,
|
|
userId: payload.userId,
|
|
});
|
|
return res.status(401).json({
|
|
error: "Session not found",
|
|
code: "SESSION_NOT_FOUND",
|
|
});
|
|
}
|
|
|
|
const session = sessionRecords[0];
|
|
|
|
const sessionExpiryTime = new Date(session.expiresAt).getTime();
|
|
const currentTime = Date.now();
|
|
const isExpired = sessionExpiryTime < currentTime;
|
|
|
|
if (isExpired) {
|
|
databaseLogger.warn("Session has expired", {
|
|
operation: "session_expired",
|
|
sessionId: payload.sessionId,
|
|
expiresAt: session.expiresAt,
|
|
expiryTime: sessionExpiryTime,
|
|
currentTime: currentTime,
|
|
difference: currentTime - sessionExpiryTime,
|
|
});
|
|
|
|
db.delete(sessions)
|
|
.where(eq(sessions.id, payload.sessionId))
|
|
.then(async () => {
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
|
|
const remainingSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, payload.userId));
|
|
|
|
if (remainingSessions.length === 0) {
|
|
this.userCrypto.logoutUser(payload.userId);
|
|
}
|
|
} catch (cleanupError) {
|
|
databaseLogger.error(
|
|
"Failed to cleanup after expired session",
|
|
cleanupError,
|
|
{
|
|
operation: "expired_session_cleanup_failed",
|
|
sessionId: payload.sessionId,
|
|
},
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
databaseLogger.error(
|
|
"Failed to delete expired session",
|
|
error,
|
|
{
|
|
operation: "expired_session_delete_failed",
|
|
sessionId: payload.sessionId,
|
|
},
|
|
);
|
|
});
|
|
|
|
return res.status(401).json({
|
|
error: "Session has expired",
|
|
code: "SESSION_EXPIRED",
|
|
});
|
|
}
|
|
|
|
db.update(sessions)
|
|
.set({ lastActiveAt: new Date().toISOString() })
|
|
.where(eq(sessions.id, payload.sessionId))
|
|
.then(() => {})
|
|
.catch((error) => {
|
|
databaseLogger.warn("Failed to update session lastActiveAt", {
|
|
operation: "session_update_last_active",
|
|
sessionId: payload.sessionId,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
});
|
|
});
|
|
} catch (error) {
|
|
databaseLogger.error("Session check failed in middleware", error, {
|
|
operation: "middleware_session_check_failed",
|
|
sessionId: payload.sessionId,
|
|
});
|
|
return res.status(500).json({ error: "Session check failed" });
|
|
}
|
|
}
|
|
|
|
authReq.userId = payload.userId;
|
|
authReq.pendingTOTP = payload.pendingTOTP;
|
|
next();
|
|
};
|
|
}
|
|
|
|
createDataAccessMiddleware() {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
const authReq = req as AuthenticatedRequest;
|
|
const userId = authReq.userId;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
|
|
const dataKey = this.userCrypto.getUserDataKey(userId);
|
|
authReq.dataKey = dataKey || undefined;
|
|
next();
|
|
};
|
|
}
|
|
|
|
createAdminMiddleware() {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
let token = req.cookies?.jwt;
|
|
|
|
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 payload = await this.verifyJWTToken(token);
|
|
|
|
if (!payload) {
|
|
return res.status(401).json({ error: "Invalid token" });
|
|
}
|
|
|
|
try {
|
|
const { db } = await import("../database/db/index.js");
|
|
const { users } = await import("../database/db/schema.js");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const user = await db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.id, payload.userId));
|
|
|
|
if (!user || user.length === 0 || !user[0].is_admin) {
|
|
databaseLogger.warn(
|
|
"Non-admin user attempted to access admin endpoint",
|
|
{
|
|
operation: "admin_access_denied",
|
|
userId: payload.userId,
|
|
endpoint: req.path,
|
|
},
|
|
);
|
|
return res.status(403).json({ error: "Admin access required" });
|
|
}
|
|
|
|
const authReq = req as AuthenticatedRequest;
|
|
authReq.userId = payload.userId;
|
|
authReq.pendingTOTP = payload.pendingTOTP;
|
|
next();
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to verify admin privileges", error, {
|
|
operation: "admin_check_failed",
|
|
userId: payload.userId,
|
|
});
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to verify admin privileges" });
|
|
}
|
|
};
|
|
}
|
|
|
|
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
|
if (sessionId) {
|
|
try {
|
|
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
|
|
try {
|
|
const { saveMemoryDatabaseToFile } = await import(
|
|
"../database/db/index.js"
|
|
);
|
|
await saveMemoryDatabaseToFile();
|
|
} catch (saveError) {
|
|
databaseLogger.error(
|
|
"Failed to save database after logout",
|
|
saveError,
|
|
{
|
|
operation: "logout_db_save_failed",
|
|
userId,
|
|
sessionId,
|
|
},
|
|
);
|
|
}
|
|
|
|
const remainingSessions = await db
|
|
.select()
|
|
.from(sessions)
|
|
.where(eq(sessions.userId, userId));
|
|
|
|
if (remainingSessions.length === 0) {
|
|
this.userCrypto.logoutUser(userId);
|
|
} else {
|
|
}
|
|
} catch (error) {
|
|
databaseLogger.error("Failed to delete session on logout", error, {
|
|
operation: "session_delete_logout_failed",
|
|
userId,
|
|
sessionId,
|
|
});
|
|
}
|
|
} else {
|
|
this.userCrypto.logoutUser(userId);
|
|
}
|
|
}
|
|
|
|
getUserDataKey(userId: string): Buffer | null {
|
|
return this.userCrypto.getUserDataKey(userId);
|
|
}
|
|
|
|
isUserUnlocked(userId: string): boolean {
|
|
return this.userCrypto.isUserUnlocked(userId);
|
|
}
|
|
|
|
async changeUserPassword(
|
|
userId: string,
|
|
oldPassword: string,
|
|
newPassword: string,
|
|
): Promise<boolean> {
|
|
return await this.userCrypto.changeUserPassword(
|
|
userId,
|
|
oldPassword,
|
|
newPassword,
|
|
);
|
|
}
|
|
|
|
async resetUserPasswordWithPreservedDEK(
|
|
userId: string,
|
|
newPassword: string,
|
|
): Promise<boolean> {
|
|
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
|
|
userId,
|
|
newPassword,
|
|
);
|
|
}
|
|
}
|
|
|
|
export { AuthManager, type AuthenticationResult, type JWTPayload };
|