feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web
This commit is contained in:
@@ -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();
|
||||
|
||||
243
src/backend/utils/user-agent-parser.ts
Normal file
243
src/backend/utils/user-agent-parser.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user