← back to Wine Finder Next
lib/sessionManagement.ts
455 lines
// Session Management System
// Secure session handling with Redis-like in-memory store
import crypto from 'crypto';
import { logAudit } from './auth';
import { logSecurityEvent } from './securityMonitoring';
interface Session {
id: string;
userId: string;
ipAddress: string;
userAgent: string;
fingerprint: string;
createdAt: Date;
lastActivity: Date;
expiresAt: Date;
refreshToken?: string;
metadata: {
deviceType?: string;
browser?: string;
os?: string;
location?: string;
};
flags: {
suspicious: boolean;
requiresReauth: boolean;
mfaVerified: boolean;
};
}
interface SessionActivity {
sessionId: string;
timestamp: Date;
action: string;
ipAddress: string;
userAgent: string;
}
// Session storage - in production use Redis
const sessionStore = new Map<string, Session>();
const sessionActivities = new Map<string, SessionActivity[]>();
const userSessions = new Map<string, Set<string>>(); // userId -> sessionIds
// Configuration
const SESSION_CONFIG = {
sessionTimeout: 30 * 60 * 1000, // 30 minutes inactivity
absoluteTimeout: 24 * 60 * 60 * 1000, // 24 hours absolute
maxConcurrentSessions: 5,
requireConsistentIP: false, // Can be strict for high-security
requireConsistentUA: true,
refreshTokenLifetime: 7 * 24 * 60 * 60 * 1000, // 7 days
cleanupInterval: 5 * 60 * 1000, // 5 minutes
suspiciousActivityThreshold: 10 // actions per minute
};
// Create new session
export function createSession(
userId: string,
ipAddress: string,
userAgent: string,
fingerprint?: string
): { session: Session; token: string } {
// Check concurrent sessions limit
const existingSessions = userSessions.get(userId) || new Set();
if (existingSessions.size >= SESSION_CONFIG.maxConcurrentSessions) {
// Revoke oldest session
const oldestSession = Array.from(existingSessions)[0];
revokeSession(oldestSession);
}
const sessionId = crypto.randomBytes(32).toString('hex');
const token = crypto.randomBytes(32).toString('hex');
const now = new Date();
const session: Session = {
id: sessionId,
userId,
ipAddress,
userAgent,
fingerprint: fingerprint || generateFingerprint(userAgent, ipAddress),
createdAt: now,
lastActivity: now,
expiresAt: new Date(now.getTime() + SESSION_CONFIG.absoluteTimeout),
refreshToken: crypto.randomBytes(32).toString('hex'),
metadata: parseUserAgent(userAgent),
flags: {
suspicious: false,
requiresReauth: false,
mfaVerified: false
}
};
// Store session
sessionStore.set(token, session);
// Track user sessions
if (!userSessions.has(userId)) {
userSessions.set(userId, new Set());
}
userSessions.get(userId)!.add(token);
// Log session creation
logAudit({
userId,
action: 'SESSION_CREATE',
resource: `session/${sessionId}`,
ipAddress,
userAgent,
success: true,
metadata: { fingerprint: session.fingerprint }
});
return { session, token };
}
// Verify session
export function verifySession(
token: string,
ipAddress?: string,
userAgent?: string
): { valid: boolean; session?: Session; reason?: string } {
const session = sessionStore.get(token);
if (!session) {
return { valid: false, reason: 'Invalid session token' };
}
const now = new Date();
// Check absolute expiration
if (now > session.expiresAt) {
revokeSession(token);
return { valid: false, reason: 'Session expired (absolute timeout)' };
}
// Check inactivity timeout
const inactivityTime = now.getTime() - session.lastActivity.getTime();
if (inactivityTime > SESSION_CONFIG.sessionTimeout) {
revokeSession(token);
return { valid: false, reason: 'Session expired (inactivity)' };
}
// Verify consistency checks
if (SESSION_CONFIG.requireConsistentIP && ipAddress && session.ipAddress !== ipAddress) {
session.flags.suspicious = true;
logSecurityEvent({
type: 'ANOMALOUS_BEHAVIOR',
severity: 'high',
userId: session.userId,
ipAddress: ipAddress,
userAgent: userAgent || 'unknown',
details: {
reason: 'IP address changed during session',
originalIP: session.ipAddress,
newIP: ipAddress
}
});
// Require re-authentication
session.flags.requiresReauth = true;
return { valid: false, reason: 'Security check failed - IP mismatch' };
}
if (SESSION_CONFIG.requireConsistentUA && userAgent && session.userAgent !== userAgent) {
session.flags.suspicious = true;
logSecurityEvent({
type: 'ANOMALOUS_BEHAVIOR',
severity: 'medium',
userId: session.userId,
ipAddress: ipAddress || session.ipAddress,
userAgent: userAgent,
details: {
reason: 'User agent changed during session',
originalUA: session.userAgent,
newUA: userAgent
}
});
}
// Update last activity
session.lastActivity = now;
// Track activity
trackSessionActivity(token, 'SESSION_VERIFY', ipAddress || session.ipAddress, userAgent || session.userAgent);
return { valid: true, session };
}
// Refresh session
export function refreshSession(
token: string,
refreshToken: string
): { success: boolean; newToken?: string; reason?: string } {
const session = sessionStore.get(token);
if (!session) {
return { success: false, reason: 'Invalid session' };
}
if (session.refreshToken !== refreshToken) {
logSecurityEvent({
type: 'INVALID_TOKEN',
severity: 'high',
userId: session.userId,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
details: { reason: 'Invalid refresh token attempt' }
});
return { success: false, reason: 'Invalid refresh token' };
}
// Generate new tokens
const newToken = crypto.randomBytes(32).toString('hex');
const newRefreshToken = crypto.randomBytes(32).toString('hex');
// Update session
session.refreshToken = newRefreshToken;
session.lastActivity = new Date();
// Move session to new token
sessionStore.delete(token);
sessionStore.set(newToken, session);
// Update user sessions tracking
const userSessionSet = userSessions.get(session.userId);
if (userSessionSet) {
userSessionSet.delete(token);
userSessionSet.add(newToken);
}
trackSessionActivity(newToken, 'SESSION_REFRESH', session.ipAddress, session.userAgent);
return { success: true, newToken };
}
// Revoke session
export function revokeSession(token: string): void {
const session = sessionStore.get(token);
if (session) {
// Remove from user sessions
const userSessionSet = userSessions.get(session.userId);
if (userSessionSet) {
userSessionSet.delete(token);
if (userSessionSet.size === 0) {
userSessions.delete(session.userId);
}
}
// Log revocation
logAudit({
userId: session.userId,
action: 'SESSION_REVOKE',
resource: `session/${session.id}`,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
success: true
});
// Remove session
sessionStore.delete(token);
sessionActivities.delete(token);
}
}
// Revoke all user sessions
export function revokeUserSessions(userId: string): number {
const sessions = userSessions.get(userId) || new Set();
const count = sessions.size;
sessions.forEach(token => revokeSession(token));
logAudit({
userId,
action: 'SESSION_REVOKE_ALL',
resource: `user/${userId}/sessions`,
ipAddress: 'system',
userAgent: 'system',
success: true,
metadata: { revokedCount: count }
});
return count;
}
// Track session activity
function trackSessionActivity(
sessionToken: string,
action: string,
ipAddress: string,
userAgent: string
): void {
const activity: SessionActivity = {
sessionId: sessionToken,
timestamp: new Date(),
action,
ipAddress,
userAgent
};
if (!sessionActivities.has(sessionToken)) {
sessionActivities.set(sessionToken, []);
}
const activities = sessionActivities.get(sessionToken)!;
activities.push(activity);
// Keep only last 100 activities
if (activities.length > 100) {
activities.shift();
}
// Check for suspicious activity
const recentActivities = activities.filter(
a => new Date().getTime() - a.timestamp.getTime() < 60000
);
if (recentActivities.length > SESSION_CONFIG.suspiciousActivityThreshold) {
const session = sessionStore.get(sessionToken);
if (session) {
session.flags.suspicious = true;
logSecurityEvent({
type: 'ANOMALOUS_BEHAVIOR',
severity: 'medium',
userId: session.userId,
ipAddress,
userAgent,
details: {
reason: 'Excessive session activity',
actionsPerMinute: recentActivities.length
}
});
}
}
}
// Generate device fingerprint
function generateFingerprint(userAgent: string, ipAddress: string): string {
const data = `${userAgent}::${ipAddress}`;
return crypto.createHash('sha256').update(data).digest('hex').substring(0, 16);
}
// Parse user agent for metadata
function parseUserAgent(userAgent: string): Session['metadata'] {
const metadata: Session['metadata'] = {};
// Simple parsing - in production use ua-parser-js or similar
if (/mobile/i.test(userAgent)) {
metadata.deviceType = 'mobile';
} else if (/tablet/i.test(userAgent)) {
metadata.deviceType = 'tablet';
} else {
metadata.deviceType = 'desktop';
}
if (/chrome/i.test(userAgent)) {
metadata.browser = 'Chrome';
} else if (/firefox/i.test(userAgent)) {
metadata.browser = 'Firefox';
} else if (/safari/i.test(userAgent)) {
metadata.browser = 'Safari';
} else if (/edge/i.test(userAgent)) {
metadata.browser = 'Edge';
}
if (/windows/i.test(userAgent)) {
metadata.os = 'Windows';
} else if (/mac/i.test(userAgent)) {
metadata.os = 'macOS';
} else if (/linux/i.test(userAgent)) {
metadata.os = 'Linux';
} else if (/android/i.test(userAgent)) {
metadata.os = 'Android';
} else if (/ios/i.test(userAgent)) {
metadata.os = 'iOS';
}
return metadata;
}
// Get session info
export function getSessionInfo(token: string): Session | undefined {
return sessionStore.get(token);
}
// Get user sessions
export function getUserSessions(userId: string): Session[] {
const sessionTokens = userSessions.get(userId) || new Set();
const sessions: Session[] = [];
sessionTokens.forEach(token => {
const session = sessionStore.get(token);
if (session) {
sessions.push(session);
}
});
return sessions.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
}
// Get session statistics
export function getSessionStats(): {
totalSessions: number;
activeSessions: number;
suspiciousSessions: number;
uniqueUsers: number;
averageSessionDuration: number;
} {
const now = new Date();
let activeSessions = 0;
let suspiciousSessions = 0;
let totalDuration = 0;
for (const session of sessionStore.values()) {
const inactivityTime = now.getTime() - session.lastActivity.getTime();
if (inactivityTime <= SESSION_CONFIG.sessionTimeout) {
activeSessions++;
}
if (session.flags.suspicious) {
suspiciousSessions++;
}
totalDuration += session.lastActivity.getTime() - session.createdAt.getTime();
}
return {
totalSessions: sessionStore.size,
activeSessions,
suspiciousSessions,
uniqueUsers: userSessions.size,
averageSessionDuration: sessionStore.size > 0 ? totalDuration / sessionStore.size : 0
};
}
// Cleanup expired sessions
function cleanupExpiredSessions(): void {
const now = new Date();
const tokensToDelete: string[] = [];
for (const [token, session] of sessionStore.entries()) {
if (now > session.expiresAt ||
now.getTime() - session.lastActivity.getTime() > SESSION_CONFIG.sessionTimeout) {
tokensToDelete.push(token);
}
}
tokensToDelete.forEach(token => revokeSession(token));
}
// Run cleanup periodically
setInterval(cleanupExpiredSessions, SESSION_CONFIG.cleanupInterval);
// Export configuration for testing
export { SESSION_CONFIG };