268 lines
6.8 KiB
JavaScript
268 lines
6.8 KiB
JavaScript
// 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,
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|