// src/routes/authRoutes.js const express = require('express'); const crypto = require('crypto'); const db = require('../db'); const config = require('../config'); const { sendOtpSms } = require('../services/smsService'); const { createOtp, verifyOtp } = require('../services/otpService'); const { signAccessToken, issueRefreshToken, verifyRefreshToken, rotateRefreshToken, revokeRefreshToken, } = require('../services/tokenService'); // === ADDED FOR RATE LIMITING === const { checkActiveOtpForPhone, rateLimitRequestOtpByPhone, rateLimitRequestOtpByIp, rateLimitVerifyOtpByPhone, incrementFailedVerify, checkEnumerationRateLimit, blockIpForEnumeration, isIpBlockedForEnumeration, } = require('../middleware/rateLimitMiddleware'); // === SECURITY HARDENING: INPUT VALIDATION === const { validateRequestOtpBody, validateVerifyOtpBody, validateRefreshTokenBody, validateLogoutBody, validateSignupBody, } = require('../middleware/validation'); // === SECURITY HARDENING: IP/DEVICE RISK === const { isIpBlocked, calculateRiskScore, getPreviousAuthInfo, REQUIRE_OTP_ON_SUSPICIOUS_REFRESH, } = require('../services/riskScoring'); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === const { logAuthEvent, logSuspiciousOtpAttempt, logBlockedIpLogin, logSuspiciousRefresh, checkAnomalies, RISK_LEVELS, } = require('../services/auditLogger'); // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === const { encryptPhoneNumber, decryptPhoneNumber } = require('../utils/fieldEncryption'); const { preparePhoneSearchParams } = require('../utils/encryptedPhoneSearch'); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === const { executeOtpRequestWithTiming, otpRequestDelay, executeOtpVerifyWithTiming, otpVerifyDelay } = require('../utils/timingProtection'); // === SECURITY HARDENING: ENUMERATION DETECTION === const { detectAndLogEnumeration } = require('../utils/enumerationDetection'); const router = express.Router(); // helper: normalize phone function normalizePhone(phone) { const p = phone.trim().replace(/\s+/g, ''); // Remove spaces if (p.startsWith('+')) return p; // if user enters 10-digit, prepend +91 if (p.length === 10) return '+91' + p; return p; // fallback } // helper: validate phone number format function isValidPhoneNumber(phone) { // E.164 format: + followed by 1-15 digits // Reject short codes (5-6 digits) const e164Pattern = /^\+[1-9]\d{1,14}$/; if (!e164Pattern.test(phone)) { return false; } // Reject short codes (typically 5-6 digits total) const digitsOnly = phone.replace(/\D/g, ''); if (digitsOnly.length <= 6) { return false; // Likely a short code } return true; } function sanitizeDeviceId(deviceId) { if (!deviceId || typeof deviceId !== 'string') { return 'unknown-device'; } const trimmed = deviceId.trim(); if (/^[A-Za-z0-9:_-]{4,128}$/.test(trimmed)) { return trimmed; } const hash = crypto .createHash('sha256') .update(trimmed) .digest('hex') .slice(0, 32); return `anon-${hash}`; } // POST /auth/request-otp // === SECURITY HARDENING: INPUT VALIDATION === // === ADDED FOR RATE LIMITING === // Middleware order matters: // 1. Input validation // 2. Check for active OTP first (2-minute no-resend rule) // 3. Rate limit by phone number // 4. Rate limit by IP address // 5. IP blocking check router.post( '/request-otp', validateRequestOtpBody, checkActiveOtpForPhone, rateLimitRequestOtpByPhone, rateLimitRequestOtpByIp, async (req, res) => { // === SECURITY HARDENING: IP/DEVICE RISK === // Check if IP is blocked const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; if (isIpBlocked(clientIp)) { await logBlockedIpLogin(clientIp, req.headers['user-agent']); return res.status(403).json({ success: false, message: 'Access denied from this location.', }); } // === SECURITY HARDENING: ENUMERATION PROTECTION === // Check if IP is blocked for enumeration attempts if (await isIpBlockedForEnumeration(clientIp)) { return res.status(429).json({ success: false, message: 'Too many requests. Please try again later.', }); } try { // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Execute all work with timing protection to prevent enumeration // This ensures all requests take similar time regardless of phone existence const result = await executeOtpRequestWithTiming(async () => { const { phone_number } = req.body; if (!phone_number) { return { error: 'phone_number is required', status: 400 }; } const normalizedPhone = normalizePhone(phone_number); // === SECURITY HARDENING: ENUMERATION DETECTION === // Check for enumeration attempts before processing const enumerationCheck = await detectAndLogEnumeration( clientIp, normalizedPhone, req.headers['user-agent'] ); // Apply stricter rate limiting if enumeration detected if (enumerationCheck.isEnumeration) { const enumRateLimit = await checkEnumerationRateLimit(clientIp); if (!enumRateLimit.allowed) { // Block IP if enumeration rate limit exceeded await blockIpForEnumeration(clientIp); return { error: enumRateLimit.reason || 'Too many requests. Please try again later.', status: 429, }; } } // Block high-risk enumeration attempts immediately if (enumerationCheck.shouldBlock) { await blockIpForEnumeration(clientIp); return { error: 'Too many requests. IP blocked due to enumeration attempts.', status: 429, }; } // Validate phone number format if (!isValidPhoneNumber(normalizedPhone)) { return { error: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)', status: 400 }; } const { code } = await createOtp(normalizedPhone); // Attempt to send SMS (will fallback to safe logging if Twilio fails) const smsResult = await sendOtpSms(normalizedPhone, code); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === // Log OTP request event await logAuthEvent({ action: 'otp_request', status: smsResult?.success ? 'success' : 'failed', riskLevel: RISK_LEVELS.INFO, ipAddress: clientIp, userAgent: req.headers['user-agent'], meta: { phone: normalizedPhone.replace(/\d(?=\d{4})/g, '*'), // Mask phone }, }); // Even if SMS fails, we still return success because OTP is generated // In production, you may want to return an error if SMS fails if (!smsResult || !smsResult.success) { console.warn('⚠️ SMS sending failed, but OTP was generated'); // Option 1: Still return success (current behavior - allows testing) // Option 2: Return error (uncomment below for production) // return { error: 'Failed to send OTP via SMS', status: 500 }; } return { ok: true, status: 200 }; }); // Handle errors or return success if (result.error) { return res.status(result.status || 500).json({ error: result.error }); } return res.json({ ok: true }); } catch (err) { console.error('request-otp error', err); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Even on error, ensure minimum delay to prevent timing leaks await otpRequestDelay(); return res.status(500).json({ error: 'Internal server error' }); } } ); // POST /auth/check-user // Check if user exists by phone number (for sign-in flow) // This prevents sending OTP to non-existent users router.post( '/check-user', validateRequestOtpBody, // Reuse phone number validation async (req, res) => { const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; // Check if IP is blocked if (isIpBlocked(clientIp)) { await logBlockedIpLogin(clientIp, req.headers['user-agent']); return res.status(403).json({ success: false, message: 'Access denied from this location.', }); } try { const { phone_number } = req.body; if (!phone_number) { return res.status(400).json({ success: false, message: 'phone_number is required', }); } // Normalize phone number const normalizedPhone = normalizePhone(phone_number); // Validate phone number format if (!isValidPhoneNumber(normalizedPhone)) { return res.status(400).json({ success: false, message: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)', }); } // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number before searching const phoneSearchParams = preparePhoneSearchParams(normalizedPhone); // Check if user exists and has a name (fully registered) const found = await db.query( `SELECT id, name FROM users WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE`, phoneSearchParams ); if (found.rows.length === 0) { // User not found return res.status(404).json({ success: false, error: 'USER_NOT_FOUND', message: 'User is not registered. Please sign up to create a new account.', user_exists: false }); } const user = found.rows[0]; // Check if user has a name (fully registered) const isFullyRegistered = user.name && user.name.trim() !== ''; if (isFullyRegistered) { // User exists and is fully registered - should sign in return res.json({ success: true, message: 'User is already registered. Please sign in instead.', user_exists: true }); } else { // User exists but doesn't have a name (incomplete signup) - allow signup to continue return res.status(404).json({ success: false, error: 'USER_NOT_FOUND', message: 'User is not registered. Please sign up to create a new account.', user_exists: false }); } } catch (err) { console.error('check-user error', err); return res.status(500).json({ success: false, message: 'Internal server error' }); } } ); // POST /auth/verify-otp // === SECURITY HARDENING: INPUT VALIDATION === // === ADDED FOR OTP ATTEMPT LIMIT === // Rate limit failed verification attempts per phone router.post( '/verify-otp', validateVerifyOtpBody, rateLimitVerifyOtpByPhone, async (req, res) => { // === SECURITY HARDENING: IP/DEVICE RISK === // Check if IP is blocked const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; if (isIpBlocked(clientIp)) { await logBlockedIpLogin(clientIp, req.headers['user-agent']); return res.status(403).json({ success: false, message: 'Access denied from this location.', }); } try { // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Execute all verification work with timing protection // This ensures all verification attempts take similar time regardless of outcome const verificationResult = await executeOtpVerifyWithTiming(async () => { const { phone_number, code, device_id, device_info } = req.body; if (!phone_number || !code) { return { error: 'phone_number and code are required', status: 400 }; } const normalizedPhone = normalizePhone(phone_number); // Ensure code is a string (handle case where it might come as number from JSON) // bcrypt.compare requires string, and validation expects string // Note: If code comes as number, leading zeros may be lost - client should send as string const codeString = String(code); // Debug logging (remove in production or use proper logger) console.log('[OTP Verify] Request body:', JSON.stringify({ phone_number: normalizedPhone.replace(/\d(?=\d{4})/g, '*'), code: codeString, code_type: typeof code, code_length: codeString.length, device_id: device_id?.substring(0, 20) + '...' })); const result = await verifyOtp(normalizedPhone, codeString); // === ADDED FOR OTP ATTEMPT LIMIT === // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === // Use generic error message to avoid leaking information if (!result.ok) { // Increment failed verification counter try { await incrementFailedVerify(normalizedPhone); } catch (err) { console.error('Failed to increment failed verify counter:', err); // Don't fail the request if counter increment fails } // Log suspicious OTP attempt await logSuspiciousOtpAttempt( normalizedPhone, clientIp, req.headers['user-agent'], result.reason || 'invalid' ); return { success: false, message: 'OTP invalid or expired. Please request a new one.', status: 400 }; } return { ok: true, normalizedPhone, device_id, device_info }; }); // Handle verification failure if (verificationResult.error || !verificationResult.ok) { return res.status(verificationResult.status || 400).json({ success: false, message: verificationResult.message || 'OTP invalid or expired. Please request a new one.' }); } // Continue with successful verification const { normalizedPhone, device_id, device_info } = verificationResult; // find or create user // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number before storing/searching const encryptedPhone = encryptPhoneNumber(normalizedPhone); const phoneSearchParams = preparePhoneSearchParams(normalizedPhone); let user; // Check if token_version column exists, use it if available, otherwise default to 1 const found = await db.query( `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version FROM users WHERE phone_number = $1 OR phone_number = $2`, phoneSearchParams ).catch(async (err) => { // If token_version column doesn't exist, try without it if (err.code === '42703' && err.message.includes('token_version')) { console.warn('token_version column not found, using default value of 1'); return await db.query( `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version FROM users WHERE phone_number = $1 OR phone_number = $2`, phoneSearchParams ); } throw err; }); if (found.rows.length === 0) { // User not found - create a minimal user for signup flow // Extract country code from phone number const countryCode = normalizedPhone.startsWith('+') ? normalizedPhone.match(/^\+(\d{1,3})/)?.[0] || '+91' : '+91'; // Create new user with minimal data (name will be added via signup endpoint) const newUserResult = await db.query( `INSERT INTO users (phone_number, country_code, language, timezone) VALUES ($1, $2, $3, $4) RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version`, [ encryptedPhone, countryCode, device_info?.language_code || null, device_info?.timezone || null ] ).catch(async (err) => { if (err.code === '42703' && err.message.includes('token_version')) { const result = await db.query( `INSERT INTO users (phone_number, country_code, language, timezone) VALUES ($1, $2, $3, $4) RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version`, [ encryptedPhone, countryCode, device_info?.language_code || null, device_info?.timezone || null ] ); result.rows[0].token_version = 1; return result; } throw err; }); user = newUserResult.rows[0]; } else { user = found.rows[0]; } // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Decrypt phone number before returning to client if (user.phone_number) { user.phone_number = decryptPhoneNumber(user.phone_number); } // update last_login_at await db.query( `UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id] ); const devId = sanitizeDeviceId(device_id); // Check if this is a new device for existing account const existingDevice = await db.query( `SELECT id FROM user_devices WHERE user_id = $1 AND device_identifier = $2`, [user.id, devId] ); const isNewDevice = existingDevice.rows.length === 0; // Check if this is a new account (user was just created or has no name) const isExistingAccount = user.name && user.name.trim() !== ''; // upsert user_devices row await db.query( ` INSERT INTO user_devices ( user_id, device_identifier, device_platform, device_model, os_version, app_version, language_code, timezone, last_seen_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ON CONFLICT (user_id, device_identifier) DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at, device_platform = EXCLUDED.device_platform, device_model = EXCLUDED.device_model, os_version = EXCLUDED.os_version, app_version = EXCLUDED.app_version, language_code = EXCLUDED.language_code, timezone = EXCLUDED.timezone, is_active = true `, [ user.id, devId, device_info?.platform || 'unknown', device_info?.model || null, device_info?.os_version || null, device_info?.app_version || null, device_info?.language_code || null, device_info?.timezone || null, ] ); // === SECURITY HARDENING: IP/DEVICE RISK === // Calculate risk score for this login const previousAuth = await getPreviousAuthInfo(user.id, devId); const riskScore = await calculateRiskScore({ userId: user.id, currentIp: clientIp, currentUserAgent: req.headers['user-agent'], currentDeviceId: devId, currentDeviceInfo: device_info, previousIp: previousAuth.previousIp, previousUserAgent: previousAuth.previousUserAgent, previousDeviceId: previousAuth.previousDeviceId, }); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === // Log OTP verification event with risk level (needed for step-up authentication) await logAuthEvent({ userId: user.id, action: 'otp_verify', status: 'success', riskLevel: riskScore.isSuspicious ? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS) : RISK_LEVELS.INFO, deviceId: devId, ipAddress: clientIp, userAgent: req.headers['user-agent'], meta: { is_new_device: isNewDevice, is_new_account: !isExistingAccount, platform: device_info?.platform || 'unknown', risk_score: riskScore.score, risk_reasons: riskScore.reasons, }, }); // Check for anomalies await checkAnomalies(user.id, 'otp_verify', clientIp); // === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION === // Issue access token with high_assurance flag if this is a fresh OTP verification // This allows step-up auth for sensitive actions const accessToken = signAccessToken(user, { highAssurance: true }); const refreshToken = await issueRefreshToken({ userId: user.id, deviceId: devId, userAgent: req.headers['user-agent'], ip: clientIp, }); const needsProfile = !user.name || !user.user_type; // Get count of active devices const deviceCountResult = await db.query( `SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`, [user.id] ); const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10); return res.json({ user, access_token: accessToken, refresh_token: refreshToken, needs_profile: needsProfile, is_new_device: isNewDevice, is_new_account: !isExistingAccount, active_devices_count: activeDevicesCount, }); } catch (err) { console.error('verify-otp error', err); // === SECURITY HARDENING: TIMING ATTACK PROTECTION === // Even on error, ensure minimum delay to prevent timing leaks await otpVerifyDelay(); return res.status(500).json({ error: 'Internal server error' }); } }); // POST /auth/refresh // === SECURITY HARDENING: INPUT VALIDATION === // === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION === // === SECURITY HARDENING: CSRF (FUTURE-PROOFING) === // NOTE: If tokens are ever moved to HTTP-only cookies, CSRF protection becomes mandatory. // Consider implementing: SameSite cookie attribute + CSRF token validation router.post( '/refresh', validateRefreshTokenBody, async (req, res) => { try { const { refresh_token } = req.body; const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; // === SECURITY HARDENING: IP/DEVICE RISK === // Check if IP is blocked if (isIpBlocked(clientIp)) { await logBlockedIpLogin(clientIp, req.headers['user-agent']); return res.status(403).json({ error: 'Access denied from this location.' }); } console.log(`[AUTH] Refresh token request received: ip=${clientIp}, userAgent=${req.headers['user-agent']}`); const verification = await verifyRefreshToken(refresh_token); if (!verification || verification.reuseDetected) { console.log(`[AUTH] Refresh token verification failed: reuseDetected=${verification?.reuseDetected || false}`); return res.status(401).json({ error: 'Invalid refresh token' }); } console.log(`[AUTH] Refresh token verified: userId=${verification.userId}, deviceId=${verification.deviceId}`); const { userId, deviceId, row: tokenRow } = verification; const { rows } = await db.query( `SELECT id, phone_number, name, role, user_type, COALESCE(token_version, 1) as token_version FROM users WHERE id = $1`, [userId] ); if (rows.length === 0) { return res.status(401).json({ error: 'User not found' }); } const user = rows[0]; // === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION === // Calculate risk score for refresh from different environment const previousAuth = await getPreviousAuthInfo(userId, deviceId); const riskScore = await calculateRiskScore({ userId, currentIp: clientIp, currentUserAgent: req.headers['user-agent'], currentDeviceId: deviceId, currentDeviceInfo: null, previousIp: tokenRow.ip_address || previousAuth.previousIp, previousUserAgent: tokenRow.user_agent || previousAuth.previousUserAgent, previousDeviceId: deviceId, }); // === SECURITY HARDENING: REFRESH TOKEN THEFT MITIGATION === // If refresh is from suspicious environment, log it if (riskScore.isSuspicious) { await logSuspiciousRefresh( userId, deviceId, clientIp, req.headers['user-agent'], riskScore.score, riskScore.reasons ); // Optionally require OTP re-verification for suspicious refreshes if (REQUIRE_OTP_ON_SUSPICIOUS_REFRESH) { return res.status(403).json({ error: 'step_up_required', message: 'Additional verification required. Please verify your OTP.', requires_otp: true, }); } } // === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION === // Preserve high_assurance status if there's been a recent OTP verification // This ensures step-up authentication remains valid after token refresh const RECENT_OTP_WINDOW_MINUTES = parseInt(process.env.STEP_UP_OTP_WINDOW_MINUTES || '5', 10); const recentOtpCheck = await db.query( `SELECT created_at FROM auth_audit WHERE user_id = $1 AND action = 'otp_verify' AND status = 'success' AND created_at > NOW() - INTERVAL '${RECENT_OTP_WINDOW_MINUTES} minutes' ORDER BY created_at DESC LIMIT 1`, [userId] ); const hasRecentOtp = recentOtpCheck.rows.length > 0; console.log(`[AUTH] Token refresh: userId=${userId}, deviceId=${deviceId}, ip=${clientIp}`); const newAccess = signAccessToken(user, { highAssurance: hasRecentOtp }); console.log(`[AUTH] New access token generated: userId=${userId}, expiresIn=${config.jwtAccessTtl}`); const newRefresh = await rotateRefreshToken({ tokenRow, userAgent: req.headers['user-agent'], ip: clientIp, }); console.log(`[AUTH] New refresh token generated and rotated: userId=${userId}, deviceId=${deviceId}`); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === // Log refresh event await logAuthEvent({ userId, action: 'token_refresh', status: 'success', riskLevel: riskScore.isSuspicious ? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS) : RISK_LEVELS.INFO, deviceId, ipAddress: clientIp, userAgent: req.headers['user-agent'], meta: { risk_score: riskScore.score, risk_reasons: riskScore.reasons, }, }); // Update device last_seen_at when tokens are refreshed try { await db.query( `UPDATE user_devices SET last_seen_at = NOW() WHERE user_id = $1 AND device_identifier = $2`, [userId, deviceId] ); } catch (deviceErr) { console.error('Failed to update device last_seen_at', deviceErr); // Don't fail the refresh if device update fails } console.log(`[AUTH] Token refresh successful: userId=${userId}, deviceId=${deviceId}`); return res.json({ access_token: newAccess, refresh_token: newRefresh }); } catch (err) { console.error('[AUTH] refresh error', err); return res.status(500).json({ error: 'Internal server error' }); } }); // POST /auth/logout // === SECURITY HARDENING: INPUT VALIDATION === router.post( '/logout', validateLogoutBody, async (req, res) => { try { const { refresh_token } = req.body; const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; const info = await verifyRefreshToken(refresh_token); if (!info || info.reuseDetected) { return res.status(200).json({ ok: true }); // already invalid -> treat as logged out } await revokeRefreshToken(info.userId, info.deviceId); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === await logAuthEvent({ userId: info.userId, action: 'logout', status: 'success', riskLevel: RISK_LEVELS.INFO, deviceId: info.deviceId, ipAddress: clientIp, userAgent: req.headers['user-agent'], }); return res.json({ ok: true }); } catch (err) { console.error('logout error', err); return res.status(500).json({ error: 'Internal server error' }); } } ); // POST /auth/signup // Signup endpoint: Create new user with name, phone, and location router.post( '/signup', validateSignupBody, async (req, res) => { const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; // Check if IP is blocked if (isIpBlocked(clientIp)) { await logBlockedIpLogin(clientIp, req.headers['user-agent']); return res.status(403).json({ success: false, message: 'Access denied from this location.', }); } try { const { name, phone_number, state, district, city_village, device_id, device_info } = req.body; // Normalize phone number const normalizedPhone = normalizePhone(phone_number); // Validate phone number format if (!isValidPhoneNumber(normalizedPhone)) { return res.status(400).json({ success: false, message: 'Invalid phone number format. Please use E.164 format (e.g., +919876543210)', }); } // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Encrypt phone number before storing/searching const encryptedPhone = encryptPhoneNumber(normalizedPhone); const phoneSearchParams = preparePhoneSearchParams(normalizedPhone); // Check if user already exists const existingUser = await db.query( `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version FROM users WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE`, phoneSearchParams ).catch(async (err) => { if (err.code === '42703' && err.message.includes('token_version')) { return await db.query( `SELECT id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version FROM users WHERE (phone_number = $1 OR phone_number = $2) AND deleted = FALSE`, phoneSearchParams ); } throw err; }); if (existingUser.rows.length > 0) { const existing = existingUser.rows[0]; // Check if user was just created by verify-otp (has no name) - allow update if (!existing.name || existing.name.trim() === '') { // User exists but has no name (was just created by verify-otp), update it with signup data const countryCode = normalizedPhone.startsWith('+') ? normalizedPhone.match(/^\+(\d{1,3})/)?.[0] || '+91' : '+91'; // Update user with name, country code, language, and timezone const updatedUser = await db.query( `UPDATE users SET name = $1, country_code = $2, language = $4, timezone = $5 WHERE id = $3 RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version, country_code, created_at`, [ name, countryCode, existing.id, device_info?.language_code || null, device_info?.timezone || null ] ).catch(async (err) => { if (err.code === '42703' && err.message.includes('token_version')) { const result = await db.query( `UPDATE users SET name = $1, country_code = $2, language = $4, timezone = $5 WHERE id = $3 RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version, country_code, created_at`, [ name, countryCode, existing.id, device_info?.language_code || null, device_info?.timezone || null ] ); result.rows[0].token_version = 1; return result; } throw err; }); const user = updatedUser.rows[0]; // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Decrypt phone number before returning to client if (user.phone_number) { user.phone_number = decryptPhoneNumber(user.phone_number); } // Create location entry if location data provided let locationId = null; if (state || district || city_village) { const locationResult = await db.query( `INSERT INTO locations (user_id, state, district, city_village, is_saved_address, location_type, source_type, source_confidence) VALUES ($1, $2, $3, $4, TRUE, 'home', 'manual', 'high') RETURNING id`, [user.id, state || null, district || null, city_village || null] ); locationId = locationResult.rows[0]?.id; } // Update last_login_at await db.query( `UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id] ); const devId = sanitizeDeviceId(device_id); // Upsert user_devices row await db.query( ` INSERT INTO user_devices ( user_id, device_identifier, device_platform, device_model, os_version, app_version, language_code, timezone, last_seen_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ON CONFLICT (user_id, device_identifier) DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at, device_platform = EXCLUDED.device_platform, device_model = EXCLUDED.device_model, os_version = EXCLUDED.os_version, app_version = EXCLUDED.app_version, language_code = EXCLUDED.language_code, timezone = EXCLUDED.timezone, is_active = true `, [ user.id, devId, device_info?.platform || 'android', device_info?.model || null, device_info?.os_version || null, device_info?.app_version || null, device_info?.language_code || null, device_info?.timezone || null, ] ); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === await logAuthEvent({ userId: user.id, action: 'signup', status: 'success', riskLevel: RISK_LEVELS.INFO, deviceId: devId, ipAddress: clientIp, userAgent: req.headers['user-agent'], meta: { is_new_account: false, is_profile_completed: true, platform: device_info?.platform || 'android', }, }); // Issue tokens const accessToken = signAccessToken(user, { highAssurance: true }); const refreshToken = await issueRefreshToken({ userId: user.id, deviceId: devId, userAgent: req.headers['user-agent'], ip: clientIp, }); // Check if profile needs completion (user_type not set) const needsProfile = !user.user_type || user.user_type === 'user'; // Get count of active devices const deviceCountResult = await db.query( `SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`, [user.id] ); const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10); return res.json({ success: true, user: { id: user.id, phone_number: user.phone_number, name: user.name, country_code: user.country_code, created_at: user.created_at, }, access_token: accessToken, refresh_token: refreshToken, needs_profile: needsProfile, is_new_account: false, is_new_device: true, active_devices_count: activeDevicesCount, location_id: locationId, }); } else { // User already exists with a name - they should sign in instead return res.status(409).json({ success: false, message: 'User with this phone number already exists. Please sign in instead.', user_exists: true, }); } } // Extract country code from phone number const countryCode = normalizedPhone.startsWith('+') ? normalizedPhone.match(/^\+(\d{1,3})/)?.[0] || '+91' : '+91'; // Create new user with all available data const newUser = await db.query( `INSERT INTO users (phone_number, name, country_code, language, timezone) VALUES ($1, $2, $3, $4, $5) RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, COALESCE(token_version, 1) as token_version, country_code, created_at`, [ encryptedPhone, name, countryCode, device_info?.language_code || null, device_info?.timezone || null ] ).catch(async (err) => { if (err.code === '42703' && err.message.includes('token_version')) { const result = await db.query( `INSERT INTO users (phone_number, name, country_code, language, timezone) VALUES ($1, $2, $3, $4, $5) RETURNING id, phone_number, name, role, NULL::user_type_enum as user_type, 1 as token_version, country_code, created_at`, [ encryptedPhone, name, countryCode, device_info?.language_code || null, device_info?.timezone || null ] ); result.rows[0].token_version = 1; return result; } throw err; }); const user = newUser.rows[0]; // === SECURITY HARDENING: FIELD-LEVEL ENCRYPTION === // Decrypt phone number before returning to client if (user.phone_number) { user.phone_number = decryptPhoneNumber(user.phone_number); } // Create location entry if location data provided let locationId = null; if (state || district || city_village) { const locationResult = await db.query( `INSERT INTO locations (user_id, state, district, city_village, is_saved_address, location_type, source_type, source_confidence) VALUES ($1, $2, $3, $4, TRUE, 'home', 'manual', 'high') RETURNING id`, [user.id, state || null, district || null, city_village || null] ); locationId = locationResult.rows[0]?.id; } // Update last_login_at await db.query( `UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id] ); const devId = sanitizeDeviceId(device_id); // Upsert user_devices row await db.query( ` INSERT INTO user_devices ( user_id, device_identifier, device_platform, device_model, os_version, app_version, language_code, timezone, last_seen_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ON CONFLICT (user_id, device_identifier) DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at, device_platform = EXCLUDED.device_platform, device_model = EXCLUDED.device_model, os_version = EXCLUDED.os_version, app_version = EXCLUDED.app_version, language_code = EXCLUDED.language_code, timezone = EXCLUDED.timezone, is_active = true `, [ user.id, devId, device_info?.platform || 'android', device_info?.model || null, device_info?.os_version || null, device_info?.app_version || null, device_info?.language_code || null, device_info?.timezone || null, ] ); // === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS === await logAuthEvent({ userId: user.id, action: 'signup', status: 'success', riskLevel: RISK_LEVELS.INFO, deviceId: devId, ipAddress: clientIp, userAgent: req.headers['user-agent'], meta: { is_new_account: true, platform: device_info?.platform || 'android', }, }); // Issue tokens const accessToken = signAccessToken(user, { highAssurance: true }); const refreshToken = await issueRefreshToken({ userId: user.id, deviceId: devId, userAgent: req.headers['user-agent'], ip: clientIp, }); // Check if profile needs completion (user_type not set) const needsProfile = !user.user_type || user.user_type === 'user'; // Get count of active devices const deviceCountResult = await db.query( `SELECT COUNT(*) as count FROM user_devices WHERE user_id = $1 AND is_active = true`, [user.id] ); const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10); return res.json({ success: true, user: { id: user.id, phone_number: user.phone_number, name: user.name, country_code: user.country_code, created_at: user.created_at, }, access_token: accessToken, refresh_token: refreshToken, needs_profile: needsProfile, is_new_account: true, is_new_device: true, active_devices_count: activeDevicesCount, location_id: locationId, }); } catch (err) { console.error('signup error', err); // Handle unique constraint violation (phone number already exists) if (err.code === '23505' && err.constraint === 'users_phone_number_key') { return res.status(409).json({ success: false, message: 'User with this phone number already exists. Please sign in instead.', user_exists: true, }); } return res.status(500).json({ success: false, message: 'Internal server error', }); } } ); module.exports = router;