feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web

This commit is contained in:
LukeGus
2025-10-31 12:55:01 -05:00
parent cf431e59ac
commit 1bc40b66b3
23 changed files with 2545 additions and 454 deletions

View File

@@ -4,6 +4,11 @@ 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;
@@ -18,6 +23,7 @@ interface AuthenticationResult {
interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
iat?: number;
exp?: number;
@@ -132,18 +138,101 @@ class AuthManager {
async generateJWTToken(
userId: string,
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
options: {
expiresIn?: string;
pendingTOTP?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
// Determine expiration based on device type
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
expiresIn = "30d"; // 30 days for desktop and mobile
} else {
expiresIn = "7d"; // 7 days for web
}
} else if (!expiresIn) {
expiresIn = "7d"; // Default
}
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
payload.pendingTOTP = true;
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "7d",
} as jwt.SignOptions);
// Create session in database if not a temporary TOTP token
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid();
payload.sessionId = sessionId;
// Generate the token first to get it for storage
const token = jwt.sign(payload, jwtSecret, {
expiresIn,
} as jwt.SignOptions);
// Calculate expiration timestamp
const expirationMs = this.parseExpiresIn(expiresIn);
const expiresAt = new Date(Date.now() + expirationMs).toISOString();
// Store session in database
try {
await db.insert(sessions).values({
id: sessionId,
userId,
jwtToken: token,
deviceType: options.deviceType,
deviceInfo: options.deviceInfo,
expiresAt,
});
databaseLogger.info("Session created", {
operation: "session_create",
userId,
sessionId,
deviceType: options.deviceType,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to create session", error, {
operation: "session_create_failed",
userId,
sessionId,
});
// Continue anyway - session tracking is non-critical
}
return token;
}
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
}
/**
* Parse expiresIn string to milliseconds
*/
private parseExpiresIn(expiresIn: string): number {
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
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> {
@@ -175,6 +264,152 @@ class AuthManager {
});
}
async revokeSession(sessionId: string): Promise<boolean> {
try {
// Get the session to blacklist the token
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length > 0) {
const session = sessionRecords[0];
this.invalidatedTokens.add(session.jwtToken);
}
// Delete the session instead of marking as revoked
await db.delete(sessions).where(eq(sessions.id, sessionId));
databaseLogger.info("Session deleted", {
operation: "session_delete",
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 {
// Get all user sessions to blacklist tokens
let query = db.select().from(sessions).where(eq(sessions.userId, userId));
const userSessions = await query;
// Add all tokens to blacklist (except the excepted one)
for (const session of userSessions) {
if (!exceptSessionId || session.id !== exceptSessionId) {
this.invalidatedTokens.add(session.jwtToken);
}
}
// Delete sessions instead of marking as revoked
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));
}
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
).length;
databaseLogger.info("User sessions deleted", {
operation: "user_sessions_delete",
userId,
exceptSessionId,
deletedCount,
});
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 {
// Get expired sessions to blacklist their tokens
const expiredSessions = await db
.select()
.from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
// Add expired tokens to blacklist
for (const session of expiredSessions) {
this.invalidatedTokens.add(session.jwtToken);
}
// Delete expired sessions
await db
.delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
if (expiredSessions.length > 0) {
databaseLogger.info("Expired sessions cleaned up", {
operation: "sessions_cleanup",
count: expiredSessions.length,
});
}
return expiredSessions.length;
} 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,
@@ -210,6 +445,55 @@ class AuthManager {
return res.status(401).json({ error: "Invalid token" });
}
// Check session status if sessionId is present
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
if (sessionRecords.length === 0) {
return res.status(401).json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
}
const session = sessionRecords[0];
// Session exists, no need to check isRevoked since we delete sessions instead
// Check if session has expired
if (new Date(session.expiresAt) < new Date()) {
return res.status(401).json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
}
// Update lastActiveAt timestamp (async, non-blocking)
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", error, {
operation: "session_check_failed",
sessionId: payload.sessionId,
});
// Continue anyway - session tracking failures shouldn't block auth
}
}
authReq.userId = payload.userId;
authReq.pendingTOTP = payload.pendingTOTP;
next();

View File

@@ -0,0 +1,243 @@
import type { Request } from "express";
export type DeviceType = "web" | "desktop" | "mobile";
export interface DeviceInfo {
type: DeviceType;
browser: string;
version: string;
os: string;
deviceInfo: string; // Formatted string like "Chrome 120 on Windows 11"
}
/**
* Detect the platform type based on request headers
*/
export function detectPlatform(req: Request): DeviceType {
const userAgent = req.headers["user-agent"] || "";
const electronHeader = req.headers["x-electron-app"];
// Electron app detection
if (electronHeader === "true") {
return "desktop";
}
// Mobile app detection
if (userAgent.includes("Termix-Mobile")) {
return "mobile";
}
// Default to web
return "web";
}
/**
* Parse User-Agent string to extract device information
*/
export function parseUserAgent(req: Request): DeviceInfo {
const userAgent = req.headers["user-agent"] || "Unknown";
const platform = detectPlatform(req);
// For Electron
if (platform === "desktop") {
return parseElectronUserAgent(userAgent);
}
// For Mobile app
if (platform === "mobile") {
return parseMobileUserAgent(userAgent);
}
// For web browsers
return parseWebUserAgent(userAgent);
}
/**
* Parse Electron app user agent
*/
function parseElectronUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
// Detect OS
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("Linux")) {
os = "Linux";
}
// Try to extract Electron version
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
if (electronMatch) {
version = electronMatch[1];
}
return {
type: "desktop",
browser: "Electron",
version,
os,
deviceInfo: `Termix Desktop on ${os}`,
};
}
/**
* Parse mobile app user agent
*/
function parseMobileUserAgent(userAgent: string): DeviceInfo {
let os = "Unknown OS";
let version = "Unknown";
// Detect mobile OS
if (userAgent.includes("Android")) {
const androidMatch = userAgent.match(/Android ([\d.]+)/);
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const iosMatch = userAgent.match(/OS ([\d_]+)/);
if (iosMatch) {
const iosVersion = iosMatch[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
// Try to extract app version (if included in UA)
const versionMatch = userAgent.match(/Termix-Mobile\/([\d.]+)/);
if (versionMatch) {
version = versionMatch[1];
}
return {
type: "mobile",
browser: "Termix Mobile",
version,
os,
deviceInfo: `Termix Mobile on ${os}`,
};
}
/**
* Parse web browser user agent
*/
function parseWebUserAgent(userAgent: string): DeviceInfo {
let browser = "Unknown Browser";
let version = "Unknown";
let os = "Unknown OS";
// Detect browser
if (userAgent.includes("Edg/")) {
const match = userAgent.match(/Edg\/([\d.]+)/);
browser = "Edge";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) {
const match = userAgent.match(/Chrome\/([\d.]+)/);
browser = "Chrome";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Firefox/")) {
const match = userAgent.match(/Firefox\/([\d.]+)/);
browser = "Firefox";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) {
const match = userAgent.match(/Version\/([\d.]+)/);
browser = "Safari";
version = match ? match[1] : "Unknown";
} else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) {
const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/);
browser = "Opera";
version = match ? match[1] : "Unknown";
}
// Detect OS
if (userAgent.includes("Windows")) {
os = parseWindowsVersion(userAgent);
} else if (userAgent.includes("Mac OS X")) {
os = parseMacVersion(userAgent);
} else if (userAgent.includes("Linux")) {
os = "Linux";
} else if (userAgent.includes("Android")) {
const match = userAgent.match(/Android ([\d.]+)/);
os = match ? `Android ${match[1]}` : "Android";
} else if (
userAgent.includes("iOS") ||
userAgent.includes("iPhone") ||
userAgent.includes("iPad")
) {
const match = userAgent.match(/OS ([\d_]+)/);
if (match) {
const iosVersion = match[1].replace(/_/g, ".");
os = `iOS ${iosVersion}`;
} else {
os = "iOS";
}
}
// Shorten version to major.minor
if (version !== "Unknown") {
const versionParts = version.split(".");
version = versionParts.slice(0, 2).join(".");
}
return {
type: "web",
browser,
version,
os,
deviceInfo: `${browser} ${version} on ${os}`,
};
}
/**
* Parse Windows version from user agent
*/
function parseWindowsVersion(userAgent: string): string {
if (userAgent.includes("Windows NT 10.0")) {
return "Windows 10/11";
} else if (userAgent.includes("Windows NT 6.3")) {
return "Windows 8.1";
} else if (userAgent.includes("Windows NT 6.2")) {
return "Windows 8";
} else if (userAgent.includes("Windows NT 6.1")) {
return "Windows 7";
} else if (userAgent.includes("Windows NT 6.0")) {
return "Windows Vista";
} else if (
userAgent.includes("Windows NT 5.1") ||
userAgent.includes("Windows NT 5.2")
) {
return "Windows XP";
}
return "Windows";
}
/**
* Parse macOS version from user agent
*/
function parseMacVersion(userAgent: string): string {
const match = userAgent.match(/Mac OS X ([\d_]+)/);
if (match) {
const version = match[1].replace(/_/g, ".");
const parts = version.split(".");
const major = parseInt(parts[0]);
const minor = parseInt(parts[1]);
// macOS naming
if (major === 10) {
if (minor >= 15) return `macOS ${major}.${minor}`;
if (minor === 14) return "macOS Mojave";
if (minor === 13) return "macOS High Sierra";
if (minor === 12) return "macOS Sierra";
} else if (major >= 11) {
return `macOS ${major}`;
}
return `macOS ${version}`;
}
return "macOS";
}