auth/src/routes/authRoutes.js

1306 lines
46 KiB
JavaScript

// 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',
});
}
}
);
// POST /auth/validate-token
// Endpoint for other services (like BuySellService) to validate JWT tokens
// This allows centralized token validation
router.post('/validate-token', async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
valid: false,
error: 'Token is required'
});
}
// Use the auth middleware logic to validate token
const jwt = require('jsonwebtoken');
const { getKeySecret, getAllKeys, validateTokenClaims } = require('../services/jwtKeys');
const db = require('../db');
// Decode token to get key ID from header
let decoded;
try {
decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header) {
return res.json({ valid: false, error: 'Invalid token format' });
}
} catch (err) {
return res.json({ valid: false, error: 'Invalid token' });
}
// Get secret for the key ID (if present) or try all keys
const keyId = decoded.header.kid;
let payload = null;
let verified = false;
if (keyId) {
const secret = getKeySecret(keyId);
if (secret) {
try {
payload = jwt.verify(token, secret);
verified = true;
} catch (err) {
return res.json({ valid: false, error: 'Invalid or expired token' });
}
}
}
// If key ID not found or not specified, try all keys (for rotation support)
if (!verified) {
const allKeys = getAllKeys();
for (const [kid, keySecret] of Object.entries(allKeys)) {
try {
payload = jwt.verify(token, keySecret);
verified = true;
break;
} catch (err) {
continue;
}
}
}
if (!verified || !payload) {
return res.json({ valid: false, error: 'Invalid or expired token' });
}
// Validate JWT claims (iss, aud, exp, iat, nbf)
const claimsValidation = validateTokenClaims(payload);
if (!claimsValidation.valid) {
return res.json({ valid: false, error: 'Invalid token claims' });
}
// Validate token_version to ensure token hasn't been invalidated by logout-all-devices
try {
const { rows } = await db.query(
`SELECT COALESCE(token_version, 1) as token_version FROM users WHERE id = $1`,
[payload.sub]
);
if (rows.length === 0) {
return res.json({ valid: false, error: 'User not found' });
}
const userTokenVersion = rows[0].token_version;
const tokenVersion = payload.token_version || 1;
// If token version doesn't match, token has been invalidated
if (tokenVersion !== userTokenVersion) {
return res.json({ valid: false, error: 'Token has been invalidated' });
}
} catch (dbErr) {
// Handle missing token_version column gracefully
if (dbErr.code === '42703' && dbErr.message && dbErr.message.includes('token_version')) {
console.warn('token_version column not found in database, skipping version check');
// Continue without token version validation
} else {
console.error('Error validating token version:', dbErr);
return res.status(500).json({ valid: false, error: 'Internal server error' });
}
}
// Token is valid - return user info
return res.json({
valid: true,
payload: {
sub: payload.sub,
role: payload.role,
user_type: payload.user_type,
phone_number: payload.phone_number,
token_version: payload.token_version || 1,
high_assurance: payload.high_assurance || false,
},
});
} catch (err) {
console.error('validate-token error:', err);
return res.status(500).json({ valid: false, error: 'Internal server error' });
}
});
module.exports = router;