From f069b6b9653ab8426abe813fe3886568a16c8aaf Mon Sep 17 00:00:00 2001 From: Caleb Peterson Date: Thu, 23 Apr 2026 18:29:02 +0100 Subject: [PATCH] Implement session management with concurrent session control #254 --- backend/src/index.ts | 5 ++ backend/src/middleware/session.ts | 39 ++++++++++ backend/src/routes/sessions.ts | 81 +++++++++++++++++++ backend/src/services/session.ts | 125 ++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 backend/src/middleware/session.ts create mode 100644 backend/src/routes/sessions.ts create mode 100644 backend/src/services/session.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 52a836d4..e31515e6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,8 @@ import { backupRouter } from './routes/backup.js'; import { pushRouter } from './routes/push.js'; import { ipAllowlistRouter } from './routes/ip-allowlist.js'; import { ipAllowlistMiddleware, initIpAllowlist } from './middleware/ip-allowlist.js'; +import { sessionsRouter } from './routes/sessions.js'; +import { sessionMiddleware } from './middleware/session.js'; // Validate environment variables at startup validateEnv(); @@ -203,6 +205,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { }); app.use(slaTrackingMiddleware); +app.use(sessionMiddleware); app.use((req: Request, res: Response, next: NextFunction) => { if (req.method !== 'GET' && req.method !== 'HEAD') { @@ -241,6 +244,8 @@ apiV1Router.use('/backup', backupRouter); apiV1Router.use('/ip-allowlist', ipAllowlistRouter); // Push notifications apiV1Router.use('/push', pushRouter); +// Session management +apiV1Router.use('/sessions', sessionsRouter); app.use('/api/v1', ipAllowlistMiddleware(), apiV1Router); diff --git a/backend/src/middleware/session.ts b/backend/src/middleware/session.ts new file mode 100644 index 00000000..885a36a7 --- /dev/null +++ b/backend/src/middleware/session.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from 'express'; +import { updateSessionActivity, getSession, checkSessionAnomaly } from '../services/session.js'; + +/** + * Middleware to track session activity and detect anomalies. + */ +export function sessionMiddleware(req: Request, res: Response, next: NextFunction) { + const sessionId = req.headers['x-session-id'] as string; + + if (sessionId) { + const session = getSession(sessionId); + + if (session) { + if (session.status === 'terminated') { + return res.status(401).json({ + error: { + code: 'SESSION_TERMINATED', + message: 'Your session has been terminated. Please log in again.', + status: 401 + } + }); + } + + const currentIp = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || '127.0.0.1'; + + // Update activity + updateSessionActivity(sessionId, currentIp); + + // Check for anomalies + const anomaly = checkSessionAnomaly(session, currentIp); + if (anomaly) { + console.warn(`[Session Anomaly] ${anomaly} for session ${sessionId} (User: ${session.userId})`); + res.setHeader('X-Session-Warning', anomaly); + } + } + } + + next(); +} diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts new file mode 100644 index 00000000..a4c08baa --- /dev/null +++ b/backend/src/routes/sessions.ts @@ -0,0 +1,81 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { + getUserSessions, + terminateSession, + terminateOtherSessions, + getSessionHistory, + trustDevice, + createSession +} from '../services/session.js'; +import { AppError } from '../middleware/errorHandler.js'; + +export const sessionsRouter = Router(); + +// Mock user ID middleware for demo purposes +// In a real app, this would come from the auth middleware +const getUserId = (req: any) => req.headers['x-user-id'] || 'user_default'; + +// Get active sessions +sessionsRouter.get('/', asyncHandler(async (req, res) => { + const userId = getUserId(req); + const sessions = getUserSessions(userId); + res.json({ sessions }); +})); + +// Get session history +sessionsRouter.get('/history', asyncHandler(async (req, res) => { + const userId = getUserId(req); + const history = getSessionHistory(userId); + res.json({ history }); +})); + +// Create a new session (Mock login) +sessionsRouter.post('/login', asyncHandler(async (req, res) => { + const userId = getUserId(req); + const { deviceId, browser, os } = req.body; + + const ip = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || '127.0.0.1'; + + const session = createSession(userId, { + deviceId: deviceId || 'unknown', + browser: browser || req.headers['user-agent'] || 'unknown', + os: os || 'unknown', + ip + }); + + res.json({ session }); +})); + +// Terminate a specific session +sessionsRouter.delete('/:id', asyncHandler(async (req, res) => { + const id = req.params.id as string; + const success = terminateSession(id); + + if (!success) { + throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND'); + } + + res.json({ success: true }); +})); + +// Terminate all other sessions +sessionsRouter.delete('/others/:currentId', asyncHandler(async (req, res) => { + const userId = getUserId(req); + const currentId = req.params.currentId as string; + + const count = terminateOtherSessions(userId, currentId); + res.json({ success: true, terminatedCount: count }); +})); + +// Trust a device +sessionsRouter.post('/:id/trust', asyncHandler(async (req, res) => { + const id = req.params.id as string; + const success = trustDevice(id); + + if (!success) { + throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND'); + } + + res.json({ success: true }); +})); diff --git a/backend/src/services/session.ts b/backend/src/services/session.ts new file mode 100644 index 00000000..b82cef72 --- /dev/null +++ b/backend/src/services/session.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto'; + +export interface Session { + id: string; + userId: string; + deviceId: string; + browser: string; + os: string; + ip: string; + lastActive: string; + isTrusted: boolean; + status: 'active' | 'terminated'; + createdAt: string; +} + +const sessions: Map = new Map(); +const MAX_CONCURRENT_SESSIONS = 5; + +/** + * Creates a new session for a user. + * If concurrent limits are exceeded, terminates the oldest session. + */ +export function createSession(userId: string, metadata: { deviceId: string; browser: string; os: string; ip: string }): Session { + const userSessions = getUserSessions(userId); + + if (userSessions.length >= MAX_CONCURRENT_SESSIONS) { + const oldest = userSessions.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())[0]; + terminateSession(oldest.id); + } + + const session: Session = { + id: `sess_${randomUUID()}`, + userId, + ...metadata, + lastActive: new Date().toISOString(), + isTrusted: false, + status: 'active', + createdAt: new Date().toISOString(), + }; + + sessions.set(session.id, session); + return session; +} + +/** + * Gets all active sessions for a user. + */ +export function getUserSessions(userId: string): Session[] { + return Array.from(sessions.values()).filter(s => s.userId === userId && s.status === 'active'); +} + +/** + * Gets a specific session. + */ +export function getSession(sessionId: string): Session | undefined { + return sessions.get(sessionId); +} + +/** + * Terminates a specific session. + */ +export function terminateSession(sessionId: string): boolean { + const session = sessions.get(sessionId); + if (session) { + session.status = 'terminated'; + return true; + } + return false; +} + +/** + * Terminates all other sessions for a user except the current one. + */ +export function terminateOtherSessions(userId: string, currentSessionId: string): number { + let count = 0; + sessions.forEach(session => { + if (session.userId === userId && session.id !== currentSessionId && session.status === 'active') { + session.status = 'terminated'; + count++; + } + }); + return count; +} + +/** + * Updates the last active timestamp for a session. + */ +export function updateSessionActivity(sessionId: string, ip?: string): void { + const session = sessions.get(sessionId); + if (session && session.status === 'active') { + session.lastActive = new Date().toISOString(); + if (ip) session.ip = ip; + } +} + +/** + * Detects anomalies in session activity (e.g., sudden IP change). + */ +export function checkSessionAnomaly(session: Session, currentIp: string): string | null { + if (session.ip !== currentIp && !session.isTrusted) { + return 'IP_CHANGE_DETECTED'; + } + return null; +} + +/** + * Marks a device as trusted for a session. + */ +export function trustDevice(sessionId: string): boolean { + const session = sessions.get(sessionId); + if (session) { + session.isTrusted = true; + return true; + } + return false; +} + +/** + * Gets session history (including terminated ones). + */ +export function getSessionHistory(userId: string): Session[] { + return Array.from(sessions.values()) + .filter(s => s.userId === userId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); +}