// src/services/riskScoring.js // === SECURITY HARDENING: IP/DEVICE RISK === // Basic risk scoring for IP addresses and device fingerprinting const crypto = require('crypto'); const db = require('../db'); // === SECURITY HARDENING: IP/DEVICE RISK === // Configuration for suspicious IP ranges // Can be set via environment variable as comma-separated CIDR blocks // Example: BLOCKED_IP_RANGES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 const BLOCKED_IP_RANGES = (process.env.BLOCKED_IP_RANGES || '') .split(',') .map(r => r.trim()) .filter(Boolean); // === SECURITY HARDENING: IP/DEVICE RISK === // Whether to require OTP re-verification on suspicious refresh const REQUIRE_OTP_ON_SUSPICIOUS_REFRESH = process.env.REQUIRE_OTP_ON_SUSPICIOUS_REFRESH === 'true'; /** * Check if an IP address is in a blocked CIDR range * Simple implementation for common private ranges */ function isIpBlocked(ip) { if (!ip || ip === 'unknown') return false; // Check against configured blocked ranges for (const range of BLOCKED_IP_RANGES) { if (isIpInRange(ip, range)) { return true; } } // Block common private/test ranges by default const privateRanges = [ { start: '10.0.0.0', end: '10.255.255.255' }, { start: '172.16.0.0', end: '172.31.255.255' }, { start: '192.168.0.0', end: '192.168.255.255' }, { start: '127.0.0.0', end: '127.255.255.255' }, ]; for (const range of privateRanges) { if (isIpInRange(ip, range.start, range.end)) { return true; } } return false; } /** * Simple IP range check (for IPv4) */ function isIpInRange(ip, startOrCidr, end) { if (!ip) return false; // Handle CIDR notation if (startOrCidr.includes('/')) { const [network, prefix] = startOrCidr.split('/'); const mask = parseInt(prefix, 10); const ipNum = ipToNumber(ip); const networkNum = ipToNumber(network); const maskNum = (0xFFFFFFFF << (32 - mask)) >>> 0; return (ipNum & maskNum) === (networkNum & maskNum); } // Handle start-end range if (end) { const ipNum = ipToNumber(ip); const startNum = ipToNumber(startOrCidr); const endNum = ipToNumber(end); return ipNum >= startNum && ipNum <= endNum; } return false; } /** * Convert IPv4 address to number */ function ipToNumber(ip) { const parts = ip.split('.'); if (parts.length !== 4) return 0; return parts.reduce((acc, part) => (acc << 8) + parseInt(part, 10), 0) >>> 0; } /** * Create a device fingerprint from user agent and device info */ function createDeviceFingerprint(userAgent, deviceInfo = {}) { const components = [ userAgent || 'unknown', deviceInfo.platform || 'unknown', deviceInfo.model || 'unknown', deviceInfo.os_version || 'unknown', ]; const fingerprint = components.join('|'); return crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 32); } /** * Calculate risk score for a login/refresh attempt * Returns: { score: number (0-100), reasons: string[], isSuspicious: boolean } */ async function calculateRiskScore({ userId, currentIp, currentUserAgent, currentDeviceId, currentDeviceInfo, previousIp, previousUserAgent, previousDeviceId, }) { const reasons = []; let score = 0; // Check if IP is blocked if (isIpBlocked(currentIp)) { score += 50; reasons.push('blocked_ip_range'); } // Check for IP change if (previousIp && currentIp !== previousIp) { // Check if IP changed significantly (different subnet) const ipChanged = !areIpsSimilar(currentIp, previousIp); if (ipChanged) { score += 20; reasons.push('ip_change'); } } // Check for device change if (previousDeviceId && currentDeviceId !== previousDeviceId) { score += 30; reasons.push('device_change'); } // Check for user agent change if (previousUserAgent && currentUserAgent !== previousUserAgent) { const uaChanged = !areUserAgentsSimilar(currentUserAgent, previousUserAgent); if (uaChanged) { score += 15; reasons.push('user_agent_change'); } } // Check for new device (no previous data) if (!previousIp && !previousDeviceId) { // First login is normal, but still track it reasons.push('first_login'); } const isSuspicious = score >= 30; // Threshold for suspicious activity return { score: Math.min(score, 100), reasons, isSuspicious, }; } /** * Check if two IPs are in similar ranges (same /24 subnet) */ function areIpsSimilar(ip1, ip2) { if (!ip1 || !ip2) return false; const num1 = ipToNumber(ip1); const num2 = ipToNumber(ip2); // Same /24 subnet (first 24 bits match) return (num1 >>> 8) === (num2 >>> 8); } /** * Check if two user agents are similar (basic check) */ function areUserAgentsSimilar(ua1, ua2) { if (!ua1 || !ua2) return false; // Extract browser/OS from user agent (simplified) const extractKey = (ua) => { const lower = ua.toLowerCase(); if (lower.includes('chrome')) return 'chrome'; if (lower.includes('firefox')) return 'firefox'; if (lower.includes('safari')) return 'safari'; if (lower.includes('android')) return 'android'; if (lower.includes('ios')) return 'ios'; return 'unknown'; }; return extractKey(ua1) === extractKey(ua2); } /** * Get previous login/refresh information for a user */ async function getPreviousAuthInfo(userId, deviceId) { try { // Get most recent successful auth for this user const result = await db.query( `SELECT ip_address, user_agent, device_id FROM auth_audit WHERE user_id = $1 AND status = 'success' AND action IN ('login', 'token_refresh') ORDER BY created_at DESC LIMIT 1`, [userId] ); if (result.rows.length > 0) { return { previousIp: result.rows[0].ip_address, previousUserAgent: result.rows[0].user_agent, previousDeviceId: result.rows[0].device_id, }; } // Fallback: check refresh_tokens for this device if (deviceId) { const tokenResult = await db.query( `SELECT ip_address, user_agent FROM refresh_tokens WHERE user_id = $1 AND device_id = $2 ORDER BY created_at DESC LIMIT 1`, [userId, deviceId] ); if (tokenResult.rows.length > 0) { return { previousIp: tokenResult.rows[0].ip_address, previousUserAgent: tokenResult.rows[0].user_agent, previousDeviceId: deviceId, }; } } return { previousIp: null, previousUserAgent: null, previousDeviceId: null, }; } catch (err) { console.error('Error getting previous auth info:', err); return { previousIp: null, previousUserAgent: null, previousDeviceId: null, }; } } module.exports = { isIpBlocked, createDeviceFingerprint, calculateRiskScore, getPreviousAuthInfo, REQUIRE_OTP_ON_SUSPICIOUS_REFRESH, };