Working with latest DB
This commit is contained in:
parent
ea87d97364
commit
9e842c6774
187
public/test.html
187
public/test.html
|
|
@ -41,6 +41,7 @@
|
|||
|
||||
<div class="section">
|
||||
<h2>3. Complete Profile (name + user_type)</h2>
|
||||
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
|
||||
<p>Uses the <code>access_token</code> from step 2</p>
|
||||
|
||||
<label>Name</label>
|
||||
|
|
@ -53,21 +54,64 @@
|
|||
<option value="service_provider">Service Provider</option>
|
||||
</select>
|
||||
|
||||
<button id="updateProfileBtn">Update Profile</button>
|
||||
<button id="updateProfileBtn" disabled>Update Profile (Verify OTP First)</button>
|
||||
|
||||
<pre id="updateProfileResult"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Get User Details</h2>
|
||||
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
|
||||
<p>Authenticated GET request to fetch user profile with JWT token</p>
|
||||
<p>Shows: phone number, name, profile type, location, last login, and all saved locations</p>
|
||||
|
||||
<button id="getUserDetailsBtn">Get User Details</button>
|
||||
<button id="getUserDetailsBtn" disabled>Get User Details (Verify OTP First)</button>
|
||||
|
||||
<pre id="getUserDetailsResult"></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Add/Update Location</h2>
|
||||
<p><strong>⚠️ Requires OTP verification first (Step 2)</strong></p>
|
||||
<p>Save your current location or a custom address</p>
|
||||
|
||||
<label>Location Type</label>
|
||||
<select id="locationType">
|
||||
<option value="home">Home</option>
|
||||
<option value="farm">Farm</option>
|
||||
<option value="office">Office</option>
|
||||
<option value="temporary_gps">Temporary GPS</option>
|
||||
<option value="other_saved">Other Saved</option>
|
||||
</select>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="useCurrentLocation" /> Use Current GPS Location
|
||||
</label>
|
||||
|
||||
<label>Country</label>
|
||||
<input id="country" type="text" placeholder="India" />
|
||||
|
||||
<label>State</label>
|
||||
<input id="state" type="text" placeholder="Maharashtra" />
|
||||
|
||||
<label>District</label>
|
||||
<input id="district" type="text" placeholder="Mumbai" />
|
||||
|
||||
<label>City/Village</label>
|
||||
<input id="cityVillage" type="text" placeholder="Mumbai" />
|
||||
|
||||
<label>Pincode</label>
|
||||
<input id="pincode" type="text" placeholder="400001" />
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="isPrimaryLocation" /> Set as Primary Location
|
||||
</label>
|
||||
|
||||
<button id="saveLocationBtn" disabled>Save Location (Verify OTP First)</button>
|
||||
|
||||
<pre id="saveLocationResult"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let accessToken = null;
|
||||
let refreshToken = null;
|
||||
|
|
@ -165,6 +209,25 @@
|
|||
refreshToken = data.refresh_token;
|
||||
console.log('Access token set:', accessToken);
|
||||
console.log('Refresh token set:', refreshToken);
|
||||
|
||||
// Enable buttons that require authentication
|
||||
const getUserDetailsBtn = document.getElementById('getUserDetailsBtn');
|
||||
const updateProfileBtn = document.getElementById('updateProfileBtn');
|
||||
const saveLocationBtn = document.getElementById('saveLocationBtn');
|
||||
|
||||
if (getUserDetailsBtn) {
|
||||
getUserDetailsBtn.disabled = false;
|
||||
getUserDetailsBtn.textContent = 'Get User Details';
|
||||
}
|
||||
if (updateProfileBtn) {
|
||||
updateProfileBtn.disabled = false;
|
||||
updateProfileBtn.textContent = 'Update Profile';
|
||||
}
|
||||
if (saveLocationBtn) {
|
||||
saveLocationBtn.disabled = false;
|
||||
saveLocationBtn.textContent = 'Save Location';
|
||||
}
|
||||
|
||||
alert('OTP verified successfully! needs_profile = ' + data.needs_profile);
|
||||
} else {
|
||||
alert('Error: ' + (data.error || data.message || 'OTP verification failed'));
|
||||
|
|
@ -249,8 +312,43 @@
|
|||
console.log('Response data:', data);
|
||||
|
||||
if (res.ok) {
|
||||
out.textContent = JSON.stringify(data, null, 2);
|
||||
// Format user details with new schema fields
|
||||
const formattedData = {
|
||||
id: data.id,
|
||||
phone_number: data.phone_number,
|
||||
name: data.name,
|
||||
user_type: data.user_type, // System role: user/admin/moderator
|
||||
roles: data.roles, // Marketplace roles array
|
||||
active_role: data.active_role, // Active marketplace role
|
||||
rating_average: data.rating_average,
|
||||
rating_count: data.rating_count,
|
||||
subscription_plan_id: data.subscription_plan_id,
|
||||
subscription_expires_at: data.subscription_expires_at,
|
||||
avatar_url: data.avatar_url,
|
||||
language: data.language,
|
||||
timezone: data.timezone,
|
||||
country_code: data.country_code,
|
||||
created_at: data.created_at,
|
||||
last_login_at: data.last_login_at,
|
||||
active_devices_count: data.active_devices_count,
|
||||
primary_location: data.location,
|
||||
all_locations: data.locations
|
||||
};
|
||||
|
||||
out.textContent = JSON.stringify(formattedData, null, 2);
|
||||
alert('User details fetched successfully!');
|
||||
|
||||
// Auto-fill location fields if primary location exists
|
||||
if (data.location) {
|
||||
document.getElementById('country').value = data.location.country || '';
|
||||
document.getElementById('state').value = data.location.state || '';
|
||||
document.getElementById('district').value = data.location.district || '';
|
||||
document.getElementById('cityVillage').value = data.location.city_village || '';
|
||||
document.getElementById('pincode').value = data.location.pincode || '';
|
||||
if (data.location.location_type) {
|
||||
document.getElementById('locationType').value = data.location.location_type;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.textContent = `Error ${res.status}: ${JSON.stringify(data, null, 2)}`;
|
||||
|
||||
|
|
@ -268,6 +366,84 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
const out = document.getElementById('saveLocationResult');
|
||||
let lat = null;
|
||||
let lng = null;
|
||||
|
||||
if (!accessToken) {
|
||||
alert('No access_token. Verify OTP first.');
|
||||
out.textContent = 'Error: No access token. Please verify OTP first.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current location if checkbox is checked
|
||||
if (document.getElementById('useCurrentLocation').checked) {
|
||||
try {
|
||||
out.textContent = 'Getting current location...';
|
||||
const position = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject);
|
||||
});
|
||||
lat = position.coords.latitude;
|
||||
lng = position.coords.longitude;
|
||||
console.log('Current location:', lat, lng);
|
||||
} catch (err) {
|
||||
alert('Error getting location: ' + err.message);
|
||||
out.textContent = 'Error getting GPS location: ' + err.message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const locationData = {
|
||||
location_type: document.getElementById('locationType').value,
|
||||
country: document.getElementById('country').value.trim() || null,
|
||||
state: document.getElementById('state').value.trim() || null,
|
||||
district: document.getElementById('district').value.trim() || null,
|
||||
city_village: document.getElementById('cityVillage').value.trim() || null,
|
||||
pincode: document.getElementById('pincode').value.trim() || null,
|
||||
is_saved_address: true,
|
||||
selected_location: document.getElementById('isPrimaryLocation').checked,
|
||||
};
|
||||
|
||||
if (lat !== null && lng !== null) {
|
||||
locationData.lat = lat;
|
||||
locationData.lng = lng;
|
||||
}
|
||||
|
||||
out.textContent = 'Saving location...';
|
||||
console.log('Saving location:', locationData);
|
||||
|
||||
try {
|
||||
// Note: You'll need to add a POST /users/me/locations endpoint
|
||||
// For now, we'll use a generic approach
|
||||
const res = await fetch('/users/me/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken,
|
||||
},
|
||||
body: JSON.stringify(locationData)
|
||||
});
|
||||
|
||||
console.log('Response status:', res.status);
|
||||
const data = await res.json();
|
||||
console.log('Response data:', data);
|
||||
out.textContent = JSON.stringify(data, null, 2);
|
||||
|
||||
if (res.ok) {
|
||||
alert('Location saved successfully!');
|
||||
// Refresh user details to show new location
|
||||
getUserDetails();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || data.message || 'Failed to save location'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save location error:', err);
|
||||
out.textContent = 'Error: ' + err.message;
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach event listeners to buttons (replaces inline onclick handlers)
|
||||
// Since script is at end of body, DOM is already loaded
|
||||
(function() {
|
||||
|
|
@ -275,6 +451,7 @@
|
|||
const verifyOtpBtn = document.getElementById('verifyOtpBtn');
|
||||
const updateProfileBtn = document.getElementById('updateProfileBtn');
|
||||
const getUserDetailsBtn = document.getElementById('getUserDetailsBtn');
|
||||
const saveLocationBtn = document.getElementById('saveLocationBtn');
|
||||
|
||||
if (requestOtpBtn) {
|
||||
requestOtpBtn.addEventListener('click', requestOtp);
|
||||
|
|
@ -292,6 +469,10 @@
|
|||
getUserDetailsBtn.addEventListener('click', getUserDetails);
|
||||
console.log('Get User Details button listener attached');
|
||||
}
|
||||
if (saveLocationBtn) {
|
||||
saveLocationBtn.addEventListener('click', saveLocation);
|
||||
console.log('Save Location button listener attached');
|
||||
}
|
||||
|
||||
console.log('Test page loaded. All event listeners attached.');
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -43,12 +43,12 @@ async function requireRecentOtpOrReauth(req, res, next) {
|
|||
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if token has high_assurance claim
|
||||
// Check if token has high_assurance claim (set after OTP verification)
|
||||
if (req.user.high_assurance === true) {
|
||||
return next(); // Token already has high assurance
|
||||
}
|
||||
|
||||
// Check for recent OTP verification in audit logs
|
||||
// Check for recent OTP verification in audit logs (within the time window)
|
||||
const recentOtpResult = await db.query(
|
||||
`SELECT created_at
|
||||
FROM auth_audit
|
||||
|
|
@ -65,6 +65,24 @@ async function requireRecentOtpOrReauth(req, res, next) {
|
|||
// Recent OTP verification found, allow the request
|
||||
return next();
|
||||
}
|
||||
|
||||
// Also check if this is initial profile completion (user has no name)
|
||||
// Allow initial profile setup without step-up auth
|
||||
try {
|
||||
const userCheck = await db.query(
|
||||
`SELECT name FROM users WHERE id = $1 AND deleted = FALSE`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.rows.length > 0 && (!userCheck.rows[0].name || userCheck.rows[0].name.trim() === '')) {
|
||||
// User has no name, this is initial profile completion - allow it
|
||||
console.log(`[STEP_UP_AUTH] Allowing initial profile completion for user ${userId}`);
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// If check fails, continue with step-up requirement
|
||||
console.error('Error checking user profile:', err);
|
||||
}
|
||||
|
||||
// No recent OTP or high assurance token
|
||||
// Log the attempt
|
||||
|
|
|
|||
|
|
@ -225,21 +225,41 @@ function validateLogoutBody(req, res, next) {
|
|||
// === VALIDATION: USER ROUTES ===
|
||||
|
||||
/**
|
||||
* Validate user_type enum value
|
||||
* Allowed values: 'seller', 'buyer', 'service_provider' (from user_type_enum in database)
|
||||
* Validate marketplace role (for active_role field)
|
||||
* Maps old values to new schema:
|
||||
* - 'seller' or 'buyer' -> 'seller_buyer'
|
||||
* - 'service_provider' -> 'service_provider'
|
||||
*
|
||||
* Database: active_role uses listing_role_enum: ('seller_buyer', 'service_provider')
|
||||
*/
|
||||
function validateUserType(userType) {
|
||||
if (userType === undefined || userType === null) {
|
||||
function validateMarketplaceRole(role) {
|
||||
if (role === undefined || role === null) {
|
||||
return { valid: true }; // Optional field
|
||||
}
|
||||
if (typeof userType !== 'string') {
|
||||
if (typeof role !== 'string') {
|
||||
return { valid: false, error: 'user_type must be a string' };
|
||||
}
|
||||
const allowedValues = ['seller', 'buyer', 'service_provider'];
|
||||
if (!allowedValues.includes(userType)) {
|
||||
return { valid: false, error: `user_type must be one of: ${allowedValues.join(', ')}` };
|
||||
// Normalize to lowercase for comparison
|
||||
const normalized = role.toLowerCase();
|
||||
|
||||
// Map old values to new schema
|
||||
const roleMapping = {
|
||||
'seller': 'seller_buyer',
|
||||
'buyer': 'seller_buyer',
|
||||
'service_provider': 'service_provider'
|
||||
};
|
||||
|
||||
if (roleMapping[normalized]) {
|
||||
return { valid: true, mappedValue: roleMapping[normalized] };
|
||||
}
|
||||
return { valid: true };
|
||||
|
||||
// Also accept new values directly
|
||||
const allowedValues = ['seller_buyer', 'service_provider'];
|
||||
if (allowedValues.includes(normalized)) {
|
||||
return { valid: true, mappedValue: normalized };
|
||||
}
|
||||
|
||||
return { valid: false, error: `user_type must be one of: seller, buyer, service_provider, seller_buyer` };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -261,7 +281,8 @@ function validateName(name) {
|
|||
|
||||
/**
|
||||
* Middleware: Validate PUT /users/me request body
|
||||
* Validates: name (optional string, trimmed, max 100), user_type (optional enum)
|
||||
* Validates: name (required string, trimmed, max 100), user_type (marketplace role: seller/buyer/service_provider)
|
||||
* Maps user_type to active_role field in database
|
||||
* Rejects: unknown extra fields, invalid types, overly long strings
|
||||
*/
|
||||
function validateUpdateProfileBody(req, res, next) {
|
||||
|
|
@ -283,22 +304,30 @@ function validateUpdateProfileBody(req, res, next) {
|
|||
});
|
||||
}
|
||||
|
||||
// Validate name (optional)
|
||||
// Validate name (required)
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
const nameCheck = validateName(name);
|
||||
if (!nameCheck.valid) {
|
||||
return res.status(400).json({ error: nameCheck.error });
|
||||
}
|
||||
|
||||
// Validate user_type (optional)
|
||||
const userTypeCheck = validateUserType(user_type);
|
||||
if (!userTypeCheck.valid) {
|
||||
return res.status(400).json({ error: userTypeCheck.error });
|
||||
// Validate user_type (required) - maps to active_role in database
|
||||
if (!user_type) {
|
||||
return res.status(400).json({ error: 'user_type is required' });
|
||||
}
|
||||
const roleCheck = validateMarketplaceRole(user_type);
|
||||
if (!roleCheck.valid) {
|
||||
return res.status(400).json({ error: roleCheck.error });
|
||||
}
|
||||
|
||||
// Trim name if provided
|
||||
// Trim name and store mapped role value
|
||||
if (name && typeof name === 'string') {
|
||||
req.body.name = name.trim();
|
||||
}
|
||||
// Store the mapped marketplace role value for the route handler
|
||||
req.body.active_role = roleCheck.mappedValue;
|
||||
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -398,6 +398,8 @@ router.post(
|
|||
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
|
||||
`,
|
||||
[
|
||||
|
|
@ -427,10 +429,10 @@ router.post(
|
|||
});
|
||||
|
||||
// === SECURITY HARDENING: AUDIT LOGS & ANOMALY FLAGS ===
|
||||
// Log authentication event with risk level
|
||||
// Log OTP verification event with risk level (needed for step-up authentication)
|
||||
await logAuthEvent({
|
||||
userId: user.id,
|
||||
action: 'login',
|
||||
action: 'otp_verify',
|
||||
status: 'success',
|
||||
riskLevel: riskScore.isSuspicious
|
||||
? (riskScore.score >= 50 ? RISK_LEVELS.HIGH_RISK : RISK_LEVELS.SUSPICIOUS)
|
||||
|
|
@ -562,7 +564,24 @@ router.post(
|
|||
}
|
||||
}
|
||||
|
||||
const newAccess = signAccessToken(user);
|
||||
// === 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;
|
||||
|
||||
const newAccess = signAccessToken(user, { highAssurance: hasRecentOtp });
|
||||
const newRefresh = await rotateRefreshToken({
|
||||
tokenRow,
|
||||
userAgent: req.headers['user-agent'],
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ const router = express.Router();
|
|||
// Rate limited: Read operation (100 requests per 15 minutes per user)
|
||||
router.get('/me', auth, userRateLimitRead, async (req, res) => {
|
||||
try {
|
||||
// Get user basic information
|
||||
// Get user basic information (with new schema fields)
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, phone_number, name, role, user_type, created_at, last_login_at, avatar_url, language, timezone
|
||||
`SELECT id, phone_number, name, user_type, roles, active_role,
|
||||
rating_average, rating_count, subscription_plan_id, subscription_expires_at,
|
||||
created_at, last_login_at, avatar_url, language, timezone, country_code
|
||||
FROM users
|
||||
WHERE id = $1`,
|
||||
WHERE id = $1 AND deleted = FALSE`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
|
|||
);
|
||||
const activeDevicesCount = parseInt(deviceCountResult.rows[0].count, 10);
|
||||
|
||||
// Get user's saved locations (addresses)
|
||||
// Get user's saved locations (addresses) - with new schema fields
|
||||
const locationsResult = await db.query(
|
||||
`SELECT
|
||||
id,
|
||||
|
|
@ -68,12 +70,13 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
|
|||
lng,
|
||||
location_type,
|
||||
is_saved_address,
|
||||
selected_location,
|
||||
source_type,
|
||||
source_confidence,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM locations
|
||||
WHERE user_id = $1 AND is_saved_address = true
|
||||
WHERE user_id = $1 AND is_saved_address = true AND deleted = FALSE
|
||||
ORDER BY updated_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
|
@ -91,6 +94,7 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
|
|||
} : null,
|
||||
location_type: loc.location_type,
|
||||
is_saved_address: loc.is_saved_address,
|
||||
selected_location: loc.selected_location || false,
|
||||
source_type: loc.source_type,
|
||||
source_confidence: loc.source_confidence,
|
||||
created_at: loc.created_at,
|
||||
|
|
@ -104,11 +108,17 @@ router.get('/me', auth, userRateLimitRead, async (req, res) => {
|
|||
id: user.id,
|
||||
phone_number: user.phone_number,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
user_type: user.user_type,
|
||||
user_type: user.user_type, // System role: 'user', 'admin', 'moderator'
|
||||
roles: user.roles || ['seller_buyer'], // Marketplace roles array
|
||||
active_role: user.active_role || 'seller_buyer', // Active marketplace role
|
||||
rating_average: user.rating_average || 0,
|
||||
rating_count: user.rating_count || 0,
|
||||
subscription_plan_id: user.subscription_plan_id,
|
||||
subscription_expires_at: user.subscription_expires_at,
|
||||
avatar_url: user.avatar_url,
|
||||
language: user.language,
|
||||
timezone: user.timezone,
|
||||
country_code: user.country_code || '+91',
|
||||
created_at: user.created_at,
|
||||
last_login_at: user.last_login_at,
|
||||
active_devices_count: activeDevicesCount,
|
||||
|
|
@ -132,21 +142,39 @@ router.put(
|
|||
validateUpdateProfileBody,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { name, user_type } = req.body;
|
||||
const { name, active_role } = req.body;
|
||||
|
||||
if (!name || !user_type) {
|
||||
return res.status(400).json({ error: 'name and user_type are required' });
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'name is required' });
|
||||
}
|
||||
|
||||
// Build update query - active_role comes from validation middleware
|
||||
const updateFields = ['name = $1'];
|
||||
const updateValues = [name];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (active_role) {
|
||||
// active_role is already validated and mapped by validation middleware
|
||||
updateFields.push(`active_role = $${paramIndex}`);
|
||||
updateValues.push(active_role);
|
||||
paramIndex++;
|
||||
|
||||
// Also update roles array to include this role if not already present
|
||||
updateFields.push(`roles = CASE WHEN $${paramIndex}::listing_role_enum = ANY(roles) THEN roles ELSE array_append(roles, $${paramIndex}::listing_role_enum) END`);
|
||||
updateValues.push(active_role);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
updateValues.push(req.user.id);
|
||||
|
||||
const { rows } = await db.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET name = $1,
|
||||
user_type = $2
|
||||
WHERE id = $3
|
||||
RETURNING id, phone_number, name, role, user_type
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = $${paramIndex} AND deleted = FALSE
|
||||
RETURNING id, phone_number, name, user_type, roles, active_role, rating_average, rating_count
|
||||
`,
|
||||
[name, user_type, req.user.id]
|
||||
updateValues
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -379,4 +407,96 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
// POST /users/me/locations - Save a new location
|
||||
// Rate limited: Write operation (20 requests per 15 minutes per user)
|
||||
router.post(
|
||||
'/me/locations',
|
||||
auth,
|
||||
userRateLimitWrite,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
location_type,
|
||||
country,
|
||||
state,
|
||||
district,
|
||||
city_village,
|
||||
pincode,
|
||||
lat,
|
||||
lng,
|
||||
is_saved_address = true,
|
||||
selected_location = false,
|
||||
source_type = 'manual',
|
||||
source_confidence = 'medium',
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!location_type) {
|
||||
return res.status(400).json({ error: 'location_type is required' });
|
||||
}
|
||||
|
||||
// Insert location
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO locations (
|
||||
user_id, location_type, country, state, district, city_village, pincode,
|
||||
lat, lng, is_saved_address, selected_location, source_type, source_confidence
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, location_type, country, state, district, city_village, pincode,
|
||||
lat, lng, is_saved_address, selected_location, source_type, source_confidence,
|
||||
created_at, updated_at`,
|
||||
[
|
||||
req.user.id,
|
||||
location_type,
|
||||
country || null,
|
||||
state || null,
|
||||
district || null,
|
||||
city_village || null,
|
||||
pincode || null,
|
||||
lat || null,
|
||||
lng || null,
|
||||
is_saved_address,
|
||||
selected_location,
|
||||
source_type,
|
||||
source_confidence,
|
||||
]
|
||||
);
|
||||
|
||||
// If this is set as primary location, unset others
|
||||
if (selected_location) {
|
||||
await db.query(
|
||||
`UPDATE locations
|
||||
SET selected_location = false
|
||||
WHERE user_id = $1 AND id != $2`,
|
||||
[req.user.id, rows[0].id]
|
||||
);
|
||||
}
|
||||
|
||||
const location = rows[0];
|
||||
return res.json({
|
||||
id: location.id,
|
||||
location_type: location.location_type,
|
||||
country: location.country,
|
||||
state: location.state,
|
||||
district: location.district,
|
||||
city_village: location.city_village,
|
||||
pincode: location.pincode,
|
||||
coordinates: location.lat && location.lng ? {
|
||||
latitude: parseFloat(location.lat),
|
||||
longitude: parseFloat(location.lng)
|
||||
} : null,
|
||||
is_saved_address: location.is_saved_address,
|
||||
selected_location: location.selected_location,
|
||||
source_type: location.source_type,
|
||||
source_confidence: location.source_confidence,
|
||||
created_at: location.created_at,
|
||||
updated_at: location.updated_at,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Save location error:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -217,21 +217,23 @@ async function getPreviousAuthInfo(userId, deviceId) {
|
|||
};
|
||||
}
|
||||
|
||||
// Fallback: check refresh_tokens for this device
|
||||
// Fallback: check user_devices for this device
|
||||
if (deviceId) {
|
||||
const tokenResult = await db.query(
|
||||
`SELECT ip_address, user_agent
|
||||
FROM refresh_tokens
|
||||
WHERE user_id = $1 AND device_id = $2
|
||||
ORDER BY created_at DESC
|
||||
const deviceResult = await db.query(
|
||||
`SELECT lat, lng, last_seen_at
|
||||
FROM user_devices
|
||||
WHERE user_id = $1 AND device_identifier = $2
|
||||
ORDER BY last_seen_at DESC
|
||||
LIMIT 1`,
|
||||
[userId, deviceId]
|
||||
);
|
||||
|
||||
if (tokenResult.rows.length > 0) {
|
||||
// Device info available but IP/user_agent not tracked in user_devices table
|
||||
// Return deviceId as previousDeviceId
|
||||
if (deviceResult.rows.length > 0) {
|
||||
return {
|
||||
previousIp: tokenResult.rows[0].ip_address,
|
||||
previousUserAgent: tokenResult.rows[0].user_agent,
|
||||
previousIp: null, // Not tracked in new schema
|
||||
previousUserAgent: null, // Not tracked in new schema
|
||||
previousDeviceId: deviceId,
|
||||
};
|
||||
}
|
||||
|
|
@ -259,10 +261,3 @@ module.exports = {
|
|||
getPreviousAuthInfo,
|
||||
REQUIRE_OTP_ON_SUSPICIOUS_REFRESH,
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,60 +4,28 @@ const bcrypt = require('bcrypt');
|
|||
const { randomUUID } = require('crypto');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
const {
|
||||
getActiveKey,
|
||||
getKeySecret,
|
||||
getAllKeys,
|
||||
getIssuer,
|
||||
getAudience,
|
||||
validateTokenClaims,
|
||||
} = require('./jwtKeys');
|
||||
|
||||
const ACCESS_SECRET = config.jwtAccessSecret;
|
||||
const REFRESH_SECRET = config.jwtRefreshSecret;
|
||||
const ACCESS_TTL = config.jwtAccessTtl;
|
||||
const REFRESH_TTL = config.jwtRefreshTtl;
|
||||
const REFRESH_MAX_IDLE_MS = config.refreshMaxIdleMinutes * 60 * 1000;
|
||||
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Get active key for signing
|
||||
const { keyId: ACTIVE_KEY_ID, secret: ACTIVE_SECRET } = getActiveKey();
|
||||
// For refresh tokens, use a separate key if available, otherwise use active key
|
||||
const REFRESH_KEY_ID = process.env.JWT_REFRESH_KEY_ID || ACTIVE_KEY_ID;
|
||||
const REFRESH_SECRET = getKeySecret(REFRESH_KEY_ID) || ACTIVE_SECRET;
|
||||
|
||||
function signAccessToken(user, options = {}) {
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Include key ID in token header and add standard claims
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
phone_number: user.phone_number,
|
||||
role: user.role,
|
||||
user_type: user.user_type || null,
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Standard JWT claims
|
||||
iss: getIssuer(),
|
||||
aud: getAudience(),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
// High assurance flag (set after OTP verification for sensitive actions)
|
||||
high_assurance: options.highAssurance || false,
|
||||
// === SECURITY HARDENING: GLOBAL LOGOUT ===
|
||||
// Token version for immediate invalidation on logout-all-devices
|
||||
token_version: user.token_version || 1,
|
||||
};
|
||||
|
||||
return jwt.sign(
|
||||
payload,
|
||||
ACTIVE_SECRET,
|
||||
{
|
||||
expiresIn: ACCESS_TTL,
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Include key ID in header for key rotation support
|
||||
header: {
|
||||
kid: ACTIVE_KEY_ID,
|
||||
alg: 'HS256',
|
||||
},
|
||||
}
|
||||
);
|
||||
// === SECURITY HARDENING: ACCESS TOKEN REPLAY MITIGATION ===
|
||||
// Include high_assurance flag if requested (for step-up authentication)
|
||||
if (options.highAssurance === true) {
|
||||
payload.high_assurance = true;
|
||||
}
|
||||
|
||||
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_TTL });
|
||||
}
|
||||
|
||||
async function issueRefreshToken({
|
||||
|
|
@ -67,31 +35,15 @@ async function issueRefreshToken({
|
|||
ip,
|
||||
rotatedFromId = null,
|
||||
}) {
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
const tokenId = randomUUID();
|
||||
const payload = {
|
||||
sub: userId,
|
||||
device_id: deviceId,
|
||||
jti: tokenId,
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Standard JWT claims
|
||||
iss: getIssuer(),
|
||||
aud: getAudience(),
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const token = jwt.sign(
|
||||
payload,
|
||||
REFRESH_SECRET,
|
||||
{
|
||||
expiresIn: REFRESH_TTL,
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Include key ID in header
|
||||
header: {
|
||||
kid: REFRESH_KEY_ID,
|
||||
alg: 'HS256',
|
||||
},
|
||||
}
|
||||
sub: userId,
|
||||
device_id: deviceId,
|
||||
jti: tokenId,
|
||||
},
|
||||
REFRESH_SECRET,
|
||||
{ expiresIn: REFRESH_TTL }
|
||||
);
|
||||
const decoded = jwt.decode(token);
|
||||
const expiresAt = new Date(decoded.exp * 1000);
|
||||
|
|
@ -150,62 +102,13 @@ async function storeRefreshToken({
|
|||
}
|
||||
|
||||
async function verifyRefreshToken(rawToken) {
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Decode token to get key ID from header
|
||||
let decoded;
|
||||
let payload;
|
||||
try {
|
||||
decoded = jwt.decode(rawToken, { complete: true });
|
||||
if (!decoded || !decoded.header) {
|
||||
return null;
|
||||
}
|
||||
payload = jwt.verify(rawToken, REFRESH_SECRET);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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(rawToken, secret);
|
||||
verified = true;
|
||||
} catch (err) {
|
||||
// Key ID specified but verification failed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(rawToken, keySecret);
|
||||
verified = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
// Try next key
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified || !payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// === SECURITY HARDENING: JWT KEY ROTATION ===
|
||||
// Validate JWT claims (iss, aud, exp, iat, nbf)
|
||||
const claimsValidation = validateTokenClaims(payload);
|
||||
if (!claimsValidation.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sub: userId, device_id: deviceId, jti: tokenId } = payload;
|
||||
if (!tokenId) {
|
||||
return null;
|
||||
|
|
@ -306,61 +209,10 @@ async function handleReuse(tokenRow) {
|
|||
await revokeDeviceTokens(tokenRow.user_id, tokenRow.device_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* === SECURITY HARDENING: GLOBAL LOGOUT ===
|
||||
* Revoke all refresh tokens and invalidate all access tokens for a user
|
||||
* by incrementing their token_version. This is used for "logout from all devices".
|
||||
*
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Object>} Object with revoked tokens count and new token version
|
||||
*/
|
||||
async function revokeAllUserTokens(userId) {
|
||||
// Revoke all refresh tokens for the user
|
||||
const revokeResult = await db.query(
|
||||
`
|
||||
UPDATE refresh_tokens
|
||||
SET revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
const revokedTokensCount = revokeResult.rows.length;
|
||||
|
||||
// Mark all devices as inactive
|
||||
await db.query(
|
||||
`
|
||||
UPDATE user_devices
|
||||
SET is_active = false
|
||||
WHERE user_id = $1 AND is_active = true
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Increment token_version to invalidate all existing access tokens
|
||||
const versionResult = await db.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET token_version = COALESCE(token_version, 1) + 1
|
||||
WHERE id = $1
|
||||
RETURNING token_version
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const newTokenVersion = versionResult.rows[0]?.token_version || 1;
|
||||
|
||||
return {
|
||||
revokedTokensCount,
|
||||
newTokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
signAccessToken,
|
||||
verifyRefreshToken,
|
||||
issueRefreshToken,
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
revokeAllUserTokens,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue