1374 lines
49 KiB
JavaScript
1374 lines
49 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) => {
|
|
const clientIp = req.ip || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.headers['x-real-ip'] || 'unknown';
|
|
const userAgent = req.headers['user-agent'] || 'unknown';
|
|
|
|
console.log(`[Auth Service] POST /auth/validate-token - Request received`);
|
|
console.log(`[Auth Service] Client IP: ${clientIp}`);
|
|
console.log(`[Auth Service] User-Agent: ${userAgent.substring(0, 80)}`);
|
|
console.log(`[Auth Service] Request body contains token: ${!!req.body?.token}`);
|
|
|
|
try {
|
|
const { token } = req.body;
|
|
|
|
if (!token) {
|
|
console.log(`[Auth Service] ❌ FAILED: Token is missing in request body`);
|
|
return res.status(400).json({
|
|
valid: false,
|
|
error: 'Token is required'
|
|
});
|
|
}
|
|
|
|
console.log(`[Auth Service] Token received (length: ${token.length} characters)`);
|
|
|
|
// 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) {
|
|
console.log(`[Auth Service] ❌ FAILED: Invalid token format - unable to decode header`);
|
|
return res.json({ valid: false, error: 'Invalid token format' });
|
|
}
|
|
console.log(`[Auth Service] Token decoded successfully`);
|
|
console.log(`[Auth Service] Token header:`, {
|
|
alg: decoded.header.alg,
|
|
kid: decoded.header.kid || 'NOT SET',
|
|
typ: decoded.header.typ,
|
|
});
|
|
} catch (err) {
|
|
console.log(`[Auth Service] ❌ FAILED: Error decoding token - ${err.message}`);
|
|
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;
|
|
let verificationMethod = null;
|
|
|
|
if (keyId) {
|
|
console.log(`[Auth Service] Key ID found in token: ${keyId}`);
|
|
const secret = getKeySecret(keyId);
|
|
if (secret) {
|
|
console.log(`[Auth Service] Attempting verification with key ID: ${keyId}`);
|
|
try {
|
|
payload = jwt.verify(token, secret);
|
|
verified = true;
|
|
verificationMethod = `keyId: ${keyId}`;
|
|
console.log(`[Auth Service] ✅ Token verified successfully using key ID: ${keyId}`);
|
|
} catch (err) {
|
|
console.log(`[Auth Service] ❌ Verification failed with key ID ${keyId}: ${err.message}`);
|
|
return res.json({ valid: false, error: 'Invalid or expired token' });
|
|
}
|
|
} else {
|
|
console.log(`[Auth Service] ⚠️ Key ID ${keyId} not found in available keys, will try all keys`);
|
|
}
|
|
} else {
|
|
console.log(`[Auth Service] ⚠️ No key ID in token header, trying all available keys`);
|
|
}
|
|
|
|
// If key ID not found or not specified, try all keys (for rotation support)
|
|
if (!verified) {
|
|
const allKeys = getAllKeys();
|
|
console.log(`[Auth Service] Attempting verification with ${Object.keys(allKeys).length} available keys`);
|
|
for (const [kid, keySecret] of Object.entries(allKeys)) {
|
|
try {
|
|
payload = jwt.verify(token, keySecret);
|
|
verified = true;
|
|
verificationMethod = `allKeys: ${kid}`;
|
|
console.log(`[Auth Service] ✅ Token verified successfully using key: ${kid}`);
|
|
break;
|
|
} catch (err) {
|
|
// Try next key silently
|
|
continue;
|
|
}
|
|
}
|
|
if (!verified) {
|
|
console.log(`[Auth Service] ❌ FAILED: Token verification failed with all available keys`);
|
|
}
|
|
}
|
|
|
|
if (!verified || !payload) {
|
|
console.log(`[Auth Service] ❌ FINAL RESULT: Token is invalid or expired`);
|
|
return res.json({ valid: false, error: 'Invalid or expired token' });
|
|
}
|
|
|
|
console.log(`[Auth Service] Token payload extracted:`, {
|
|
userId: payload.sub,
|
|
role: payload.role,
|
|
userType: payload.user_type,
|
|
exp: payload.exp ? new Date(payload.exp * 1000).toISOString() : 'NOT SET',
|
|
iat: payload.iat ? new Date(payload.iat * 1000).toISOString() : 'NOT SET',
|
|
tokenVersion: payload.token_version || 1,
|
|
});
|
|
|
|
// Validate JWT claims (iss, aud, exp, iat, nbf)
|
|
console.log(`[Auth Service] Validating JWT claims...`);
|
|
const claimsValidation = validateTokenClaims(payload);
|
|
if (!claimsValidation.valid) {
|
|
console.log(`[Auth Service] ❌ FAILED: Claims validation failed - ${claimsValidation.reason}`);
|
|
return res.json({ valid: false, error: 'Invalid token claims' });
|
|
}
|
|
console.log(`[Auth Service] ✅ Claims validation passed`);
|
|
|
|
// Validate token_version to ensure token hasn't been invalidated by logout-all-devices
|
|
console.log(`[Auth Service] Checking token version in database for user: ${payload.sub}`);
|
|
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) {
|
|
console.log(`[Auth Service] ❌ FAILED: User not found in database: ${payload.sub}`);
|
|
return res.json({ valid: false, error: 'User not found' });
|
|
}
|
|
|
|
const userTokenVersion = rows[0].token_version;
|
|
const tokenVersion = payload.token_version || 1;
|
|
|
|
console.log(`[Auth Service] Token version check:`, {
|
|
tokenVersion,
|
|
userTokenVersion,
|
|
match: tokenVersion === userTokenVersion,
|
|
});
|
|
|
|
// If token version doesn't match, token has been invalidated
|
|
if (tokenVersion !== userTokenVersion) {
|
|
console.log(`[Auth Service] ❌ FAILED: Token version mismatch - token has been invalidated`);
|
|
return res.json({ valid: false, error: 'Token has been invalidated' });
|
|
}
|
|
console.log(`[Auth Service] ✅ Token version check passed`);
|
|
} catch (dbErr) {
|
|
// Handle missing token_version column gracefully
|
|
if (dbErr.code === '42703' && dbErr.message && dbErr.message.includes('token_version')) {
|
|
console.warn('[Auth Service] ⚠️ token_version column not found in database, skipping version check');
|
|
// Continue without token version validation
|
|
} else {
|
|
console.error('[Auth Service] ❌ Error validating token version:', dbErr);
|
|
return res.status(500).json({ valid: false, error: 'Internal server error' });
|
|
}
|
|
}
|
|
|
|
// Token is valid - return user info
|
|
console.log(`[Auth Service] ✅ FINAL RESULT: Token is VALID`);
|
|
console.log(`[Auth Service] Returning user info:`, {
|
|
userId: payload.sub,
|
|
role: payload.role,
|
|
userType: payload.user_type,
|
|
verificationMethod,
|
|
});
|
|
|
|
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('[Auth Service] ❌ UNEXPECTED ERROR in validate-token:', err);
|
|
console.error('[Auth Service] Error stack:', err.stack);
|
|
return res.status(500).json({ valid: false, error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|