Created backend/src/config/constants.js to centralize all magic numbers and configuration values used throughout the backend codebase. Changes: - Created constants.js with organized sections for: - SECURITY: JWT, rate limiting, password reset - AUTH: OTP configuration and rate limiting - SERVER: Port, body limits, startup delays - MQTT: Connection settings, cache limits - NOTIFICATIONS: Push settings, quiet hours, batching - SERIAL: Validation patterns and constraints - EMAIL: Template settings and defaults - CRON: Schedule configurations - STORAGE: Avatar storage settings - Updated files to use constants: - index.js: JWT validation, rate limits, startup delays - routes/auth.js: OTP generation, rate limits, JWT expiry - services/mqtt.js: Connection timeouts, cache size - services/notifications.js: Batch size, TTL, quiet hours - utils/serialValidation.js: Serial number constraints - Added comprehensive test suite (30 tests) for constants module - All tests passing (93 total including existing tests) - Validates reasonable values and consistency between related constants Benefits: - Single source of truth for configuration values - Easier to maintain and update settings - Better documentation of what each value represents - Improved code readability by removing hardcoded numbers - Testable configuration values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
560 lines
17 KiB
JavaScript
560 lines
17 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const crypto = require('crypto');
|
||
const jwt = require('jsonwebtoken');
|
||
const rateLimit = require('express-rate-limit');
|
||
const { supabase } = require('../config/supabase');
|
||
const { sendOTPEmail } = require('../services/email');
|
||
const storage = require('../services/storage');
|
||
const { AUTH, SECURITY, STORAGE } = require('../config/constants');
|
||
|
||
// Rate limiter for OTP verification
|
||
const verifyOtpLimiter = rateLimit({
|
||
windowMs: AUTH.OTP_VERIFY_WINDOW_MS,
|
||
max: AUTH.OTP_VERIFY_MAX_ATTEMPTS,
|
||
keyGenerator: (req) => {
|
||
// Use email only - avoid IP-based limiting issues
|
||
const email = req.body?.email?.toLowerCase()?.trim();
|
||
return email || 'unknown';
|
||
},
|
||
message: { error: 'Too many verification attempts. Please try again in 15 minutes.' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
// Rate limiter for OTP request
|
||
const requestOtpLimiter = rateLimit({
|
||
windowMs: AUTH.OTP_REQUEST_WINDOW_MS,
|
||
max: AUTH.OTP_REQUEST_MAX_ATTEMPTS,
|
||
keyGenerator: (req) => {
|
||
// Use email only - avoid IP-based limiting issues
|
||
const email = req.body?.email?.toLowerCase()?.trim();
|
||
return email || 'unknown';
|
||
},
|
||
message: { error: 'Too many OTP requests. Please try again in 15 minutes.' },
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
});
|
||
|
||
/**
|
||
* POST /api/auth/check-email
|
||
* Проверяет существует ли пользователь с данным email
|
||
* Возвращает exists: true/false и имя если существует
|
||
*/
|
||
router.post('/check-email', async (req, res) => {
|
||
try {
|
||
const { email } = req.body;
|
||
|
||
if (!email || !email.includes('@')) {
|
||
return res.status(400).json({ error: 'Valid email is required' });
|
||
}
|
||
|
||
const normalizedEmail = email.toLowerCase().trim();
|
||
|
||
// Проверяем существует ли пользователь
|
||
const { data: existingUser } = await supabase
|
||
.from('users')
|
||
.select('id, email, first_name')
|
||
.eq('email', normalizedEmail)
|
||
.single();
|
||
|
||
if (existingUser) {
|
||
res.json({
|
||
exists: true,
|
||
name: existingUser.first_name || null
|
||
});
|
||
} else {
|
||
res.json({
|
||
exists: false
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Check email error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* POST /api/auth/request-otp
|
||
* Отправляет OTP код на email
|
||
* Если пользователя нет - создаёт нового
|
||
* Rate limited: 3 requests per 15 minutes per email/IP
|
||
*/
|
||
router.post('/request-otp', requestOtpLimiter, async (req, res) => {
|
||
try {
|
||
const { email } = req.body;
|
||
|
||
if (!email || !email.includes('@')) {
|
||
return res.status(400).json({ error: 'Valid email is required' });
|
||
}
|
||
|
||
const normalizedEmail = email.toLowerCase().trim();
|
||
|
||
// Проверяем существует ли пользователь в таблице users
|
||
const { data: existingUser } = await supabase
|
||
.from('users')
|
||
.select('id, email, first_name')
|
||
.eq('email', normalizedEmail)
|
||
.single();
|
||
|
||
let isNewUser = !existingUser;
|
||
let userId = existingUser?.id;
|
||
|
||
// Если пользователя нет - создаём
|
||
if (isNewUser) {
|
||
const { data: newUser, error: createError } = await supabase
|
||
.from('users')
|
||
.insert({
|
||
email: normalizedEmail
|
||
})
|
||
.select()
|
||
.single();
|
||
|
||
if (createError) {
|
||
console.error('Create user error:', createError);
|
||
return res.status(500).json({ error: 'Failed to create user' });
|
||
}
|
||
|
||
userId = newUser.id;
|
||
}
|
||
|
||
// Генерируем 6-значный OTP код
|
||
const otpCode = crypto.randomInt(AUTH.OTP_CODE_MIN, AUTH.OTP_CODE_MAX).toString();
|
||
const expiresAt = new Date(Date.now() + AUTH.OTP_EXPIRY_MS);
|
||
|
||
// Удаляем старые неиспользованные коды для этого email
|
||
await supabase
|
||
.from('otp_codes')
|
||
.delete()
|
||
.eq('email', normalizedEmail)
|
||
.is('used_at', null);
|
||
|
||
// Сохраняем новый код
|
||
const { error: otpError } = await supabase
|
||
.from('otp_codes')
|
||
.insert({
|
||
email: normalizedEmail,
|
||
code: otpCode,
|
||
expires_at: expiresAt.toISOString()
|
||
});
|
||
|
||
if (otpError) {
|
||
console.error('OTP save error:', otpError);
|
||
return res.status(500).json({ error: 'Failed to generate OTP' });
|
||
}
|
||
|
||
// Отправляем email
|
||
console.log(`[OTP] Sending code ${otpCode} to ${normalizedEmail}`);
|
||
const emailSent = await sendOTPEmail(normalizedEmail, otpCode, existingUser?.first_name);
|
||
console.log(`[OTP] Email sent result: ${emailSent}`);
|
||
|
||
if (!emailSent) {
|
||
return res.status(500).json({ error: 'Failed to send email' });
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'OTP sent to email',
|
||
isNewUser
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Request OTP error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* POST /api/auth/verify-otp
|
||
* Проверяет OTP код и возвращает JWT токен
|
||
* Rate limited: 5 attempts per 15 minutes per email/IP
|
||
*/
|
||
router.post('/verify-otp', verifyOtpLimiter, async (req, res) => {
|
||
try {
|
||
const { email, code } = req.body;
|
||
|
||
if (!email || !code) {
|
||
return res.status(400).json({ error: 'Email and code are required' });
|
||
}
|
||
|
||
const normalizedEmail = email.toLowerCase().trim();
|
||
|
||
// Ищем валидный OTP (used_at IS NULL означает неиспользованный)
|
||
const { data: otpRecord, error: otpError } = await supabase
|
||
.from('otp_codes')
|
||
.select('*')
|
||
.eq('email', normalizedEmail)
|
||
.eq('code', code)
|
||
.is('used_at', null)
|
||
.gt('expires_at', new Date().toISOString())
|
||
.single();
|
||
|
||
if (otpError || !otpRecord) {
|
||
return res.status(401).json({ error: 'Invalid or expired code' });
|
||
}
|
||
|
||
// Помечаем код как использованный
|
||
await supabase
|
||
.from('otp_codes')
|
||
.update({ used_at: new Date().toISOString() })
|
||
.eq('id', otpRecord.id);
|
||
|
||
// Получаем пользователя из таблицы users
|
||
const { data: user, error: userError } = await supabase
|
||
.from('users')
|
||
.select('*')
|
||
.eq('email', normalizedEmail)
|
||
.single();
|
||
|
||
if (userError || !user) {
|
||
return res.status(404).json({ error: 'User not found' });
|
||
}
|
||
|
||
// Обновляем last_login_at
|
||
await supabase
|
||
.from('users')
|
||
.update({ last_login_at: new Date().toISOString() })
|
||
.eq('id', user.id);
|
||
|
||
// Получаем beneficiaries через user_access
|
||
const { data: accessRecords } = await supabase
|
||
.from('user_access')
|
||
.select(`
|
||
beneficiary_id,
|
||
role,
|
||
granted_at,
|
||
custom_name,
|
||
beneficiaries:beneficiary_id (
|
||
id,
|
||
name,
|
||
phone,
|
||
address,
|
||
avatar_url,
|
||
equipment_status,
|
||
created_at
|
||
)
|
||
`)
|
||
.eq('accessor_id', user.id);
|
||
|
||
// Форматируем beneficiaries с displayName
|
||
const beneficiaries = (accessRecords || []).map(record => {
|
||
const customName = record.custom_name || null;
|
||
const originalName = record.beneficiaries?.name || null;
|
||
const displayName = customName || originalName;
|
||
|
||
return {
|
||
id: record.beneficiary_id,
|
||
role: record.role,
|
||
grantedAt: record.granted_at,
|
||
customName: customName,
|
||
displayName: displayName,
|
||
originalName: originalName,
|
||
name: record.beneficiaries?.name || null,
|
||
phone: record.beneficiaries?.phone || null,
|
||
address: record.beneficiaries?.address || null,
|
||
avatarUrl: record.beneficiaries?.avatar_url || null,
|
||
equipmentStatus: record.beneficiaries?.equipment_status || 'none',
|
||
hasDevices: record.beneficiaries?.equipment_status === 'active' || record.beneficiaries?.equipment_status === 'demo',
|
||
createdAt: record.beneficiaries?.created_at || null
|
||
};
|
||
});
|
||
|
||
// Генерируем JWT токен
|
||
const token = jwt.sign(
|
||
{
|
||
userId: user.id,
|
||
email: user.email
|
||
},
|
||
process.env.JWT_SECRET,
|
||
{ expiresIn: process.env.JWT_EXPIRES_IN || SECURITY.JWT_DEFAULT_EXPIRES_IN }
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
token,
|
||
user: {
|
||
id: user.id,
|
||
email: user.email,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
phone: user.phone,
|
||
role: user.role || 'user',
|
||
avatarUrl: user.avatar_url || null
|
||
},
|
||
beneficiaries
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Verify OTP error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* GET /api/auth/me
|
||
* Возвращает текущего пользователя по JWT токену
|
||
*/
|
||
router.get('/me', async (req, res) => {
|
||
try {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({ error: 'No token provided' });
|
||
}
|
||
|
||
const token = authHeader.split(' ')[1];
|
||
|
||
let decoded;
|
||
try {
|
||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||
} catch (err) {
|
||
return res.status(401).json({ error: 'Invalid token' });
|
||
}
|
||
|
||
// Получаем пользователя из таблицы users
|
||
const { data: user, error } = await supabase
|
||
.from('users')
|
||
.select('*')
|
||
.eq('id', decoded.userId)
|
||
.single();
|
||
|
||
if (error || !user) {
|
||
return res.status(404).json({ error: 'User not found' });
|
||
}
|
||
|
||
// Получаем beneficiaries через user_access
|
||
const { data: accessRecords } = await supabase
|
||
.from('user_access')
|
||
.select(`
|
||
beneficiary_id,
|
||
role,
|
||
granted_at,
|
||
custom_name,
|
||
beneficiaries:beneficiary_id (
|
||
id,
|
||
name,
|
||
phone,
|
||
address,
|
||
avatar_url,
|
||
equipment_status,
|
||
created_at
|
||
)
|
||
`)
|
||
.eq('accessor_id', user.id);
|
||
|
||
// Форматируем beneficiaries с displayName
|
||
const beneficiaries = (accessRecords || []).map(record => {
|
||
const customName = record.custom_name || null;
|
||
const originalName = record.beneficiaries?.name || null;
|
||
const displayName = customName || originalName;
|
||
|
||
return {
|
||
id: record.beneficiary_id,
|
||
role: record.role,
|
||
grantedAt: record.granted_at,
|
||
customName: customName,
|
||
displayName: displayName,
|
||
originalName: originalName,
|
||
name: record.beneficiaries?.name || null,
|
||
phone: record.beneficiaries?.phone || null,
|
||
address: record.beneficiaries?.address || null,
|
||
avatarUrl: record.beneficiaries?.avatar_url || null,
|
||
equipmentStatus: record.beneficiaries?.equipment_status || 'none',
|
||
hasDevices: record.beneficiaries?.equipment_status === 'active' || record.beneficiaries?.equipment_status === 'demo',
|
||
createdAt: record.beneficiaries?.created_at || null
|
||
};
|
||
});
|
||
|
||
res.json({
|
||
user: {
|
||
id: user.id,
|
||
email: user.email,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
phone: user.phone,
|
||
role: user.role || 'user',
|
||
avatarUrl: user.avatar_url || null
|
||
},
|
||
beneficiaries
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Get me error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* PATCH /api/auth/profile
|
||
* Обновляет профиль пользователя
|
||
*/
|
||
router.patch('/profile', async (req, res) => {
|
||
try {
|
||
console.log('[AUTH] PATCH /profile - body:', JSON.stringify(req.body));
|
||
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({ error: 'No token provided' });
|
||
}
|
||
|
||
const token = authHeader.split(' ')[1];
|
||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||
console.log('[AUTH] PATCH /profile - userId:', decoded.userId);
|
||
|
||
const { firstName, lastName, phone, addressStreet, addressCity, addressZip, addressState, addressCountry } = req.body;
|
||
|
||
const updateData = {
|
||
updated_at: new Date().toISOString()
|
||
};
|
||
|
||
if (firstName !== undefined) updateData.first_name = firstName;
|
||
if (lastName !== undefined) updateData.last_name = lastName;
|
||
if (phone !== undefined) updateData.phone = phone;
|
||
if (addressStreet !== undefined) updateData.address_street = addressStreet;
|
||
if (addressCity !== undefined) updateData.address_city = addressCity;
|
||
if (addressZip !== undefined) updateData.address_zip = addressZip;
|
||
if (addressState !== undefined) updateData.address_state = addressState;
|
||
if (addressCountry !== undefined) updateData.address_country = addressCountry;
|
||
|
||
const { data: user, error } = await supabase
|
||
.from('users')
|
||
.update(updateData)
|
||
.eq('id', decoded.userId)
|
||
.select()
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error('[AUTH] PATCH /profile - DB error:', error);
|
||
return res.status(500).json({ error: 'Failed to update profile' });
|
||
}
|
||
|
||
console.log('[AUTH] PATCH /profile - success! User:', user.id, 'firstName:', user.first_name);
|
||
|
||
res.json({
|
||
success: true,
|
||
user: {
|
||
id: user.id,
|
||
email: user.email,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
phone: user.phone,
|
||
addressStreet: user.address_street,
|
||
addressCity: user.address_city,
|
||
addressZip: user.address_zip,
|
||
addressState: user.address_state,
|
||
addressCountry: user.address_country
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Update profile error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* PATCH /api/auth/avatar
|
||
* Upload/update user profile avatar
|
||
* - Uploads to MinIO if configured
|
||
* - Falls back to base64 in DB if MinIO not available
|
||
*/
|
||
router.patch('/avatar', async (req, res) => {
|
||
try {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({ error: 'No token provided' });
|
||
}
|
||
|
||
const token = authHeader.split(' ')[1];
|
||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||
const userId = decoded.userId;
|
||
|
||
const { avatar } = req.body; // base64 string or null to remove
|
||
|
||
console.log('[AUTH] Avatar update:', { userId, hasAvatar: !!avatar });
|
||
|
||
// Validate base64 if provided
|
||
if (avatar && !avatar.startsWith('data:image/')) {
|
||
return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
|
||
}
|
||
|
||
let avatarUrl = null;
|
||
|
||
if (avatar) {
|
||
// Try to upload to MinIO
|
||
if (storage.isConfigured()) {
|
||
try {
|
||
// Get current avatar to delete old file
|
||
const { data: current } = await supabase
|
||
.from('users')
|
||
.select('avatar_url')
|
||
.eq('id', userId)
|
||
.single();
|
||
|
||
// Delete old avatar from MinIO if exists
|
||
if (current?.avatar_url && current.avatar_url.includes('minio')) {
|
||
const oldKey = storage.extractKeyFromUrl(current.avatar_url);
|
||
if (oldKey) {
|
||
try {
|
||
await storage.deleteFile(oldKey);
|
||
} catch (e) {
|
||
console.warn('[AUTH] Failed to delete old avatar:', e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Upload new avatar to MinIO
|
||
const filename = `${STORAGE.AVATAR_FILENAME_PREFIX}${userId}-${Date.now()}`;
|
||
const result = await storage.uploadBase64Image(avatar, STORAGE.AVATAR_FOLDER, filename);
|
||
avatarUrl = result.url;
|
||
|
||
console.log('[AUTH] Avatar uploaded to MinIO:', avatarUrl);
|
||
} catch (uploadError) {
|
||
console.error('[AUTH] MinIO upload failed, falling back to DB:', uploadError.message);
|
||
// Fallback: store base64 in DB
|
||
avatarUrl = avatar;
|
||
}
|
||
} else {
|
||
// MinIO not configured - store base64 in DB
|
||
console.log('[AUTH] MinIO not configured, storing base64 in DB');
|
||
avatarUrl = avatar;
|
||
}
|
||
}
|
||
|
||
// Update avatar_url in users table
|
||
const { data: user, error } = await supabase
|
||
.from('users')
|
||
.update({
|
||
avatar_url: avatarUrl,
|
||
updated_at: new Date().toISOString()
|
||
})
|
||
.eq('id', userId)
|
||
.select('id, email, first_name, last_name, avatar_url')
|
||
.single();
|
||
|
||
if (error) {
|
||
console.error('[AUTH] Avatar update error:', error);
|
||
return res.status(500).json({ error: 'Failed to update avatar' });
|
||
}
|
||
|
||
console.log('[AUTH] Avatar updated:', { userId, avatarUrl: user.avatar_url?.substring(0, 50) });
|
||
|
||
res.json({
|
||
success: true,
|
||
user: {
|
||
id: user.id,
|
||
email: user.email,
|
||
firstName: user.first_name,
|
||
lastName: user.last_name,
|
||
avatarUrl: user.avatar_url
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('[AUTH] Avatar error:', error);
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|