/** * Auth Controller - Authentication logic (PostgreSQL) */ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { getPool } = require('../db'); const googleService = require('../services/google.service'); const emailService = require('../services/email.service'); const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; // Short-lived access token const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d'; // Long refresh token // Security check: Ensure JWT_SECRET is configured if (!JWT_SECRET) { console.error('⚠️ SECURITY WARNING: JWT_SECRET environment variable is not set!'); if (process.env.NODE_ENV === 'production') { console.error('❌ FATAL: Cannot run in production without JWT_SECRET'); process.exit(1); } } /** * Generate JWT tokens */ function generateTokens(user) { const accessToken = jwt.sign( { id: user.id, sub: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } ); const refreshToken = jwt.sign( { id: user.id, type: 'refresh' }, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN } ); return { accessToken, refreshToken }; } /** * Register new user */ async function register(req, res) { const { name, email, password } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { // Check if user exists const existingResult = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if (existingResult.rows.length > 0) { return res.status(400).json({ error: 'Email already registered' }); } // Hash password const hashedPassword = await bcrypt.hash(password, 12); // Create user const query = ` INSERT INTO users (name, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id, name, email, role, created_at `; const result = await pool.query(query, [name, email, hashedPassword, 'tourist']); const user = result.rows[0]; const tokens = generateTokens(user); res.status(201).json({ user, tokens }); } catch (error) { console.error('[Register Error]', error.message); res.status(500).json({ error: 'Registration failed' }); } } // In-memory OTP store (use Redis in production) const otpStore = new Map(); /** * Send OTP to email for verification */ async function sendOtp(req, res) { const { email } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); if (!email) { return res.status(400).json({ error: 'Email is required' }); } try { // Check if email already registered const existingResult = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if (existingResult.rows.length > 0) { return res.status(400).json({ error: 'Email already registered' }); } // Generate 6-digit OTP const otp = Math.floor(100000 + Math.random() * 900000).toString(); const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes // Store OTP otpStore.set(email, { otp, expiresAt }); // Send email with OTP try { await emailService.sendOtpEmail(email, otp); console.log(`[SendOTP] OTP sent to ${email}`); } catch (emailErr) { console.error('[SendOTP] Email send failed:', emailErr.message); // Still return success if email fails in dev - OTP is logged if (process.env.NODE_ENV === 'production') { return res.status(500).json({ error: 'Failed to send verification email' }); } } // Return success (hide OTP in production) res.json({ success: true, message: 'OTP sent to email', // DEV ONLY - remove in production: _devOtp: process.env.NODE_ENV !== 'production' ? otp : undefined }); } catch (error) { console.error('[SendOTP Error]', error.message); res.status(500).json({ error: 'Failed to send OTP' }); } } /** * Verify OTP (internal helper) */ function verifyOtp(email, otp) { const stored = otpStore.get(email); if (!stored) return { valid: false, error: 'OTP not found. Please request a new one.' }; if (Date.now() > stored.expiresAt) { otpStore.delete(email); return { valid: false, error: 'OTP expired. Please request a new one.' }; } if (stored.otp !== otp) return { valid: false, error: 'Invalid OTP' }; otpStore.delete(email); // Clear after verification return { valid: true }; } /** * Login with email/password */ async function login(req, res) { const { email, password } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]); if (result.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } const user = result.rows[0]; const validPassword = await bcrypt.compare(password, user.password_hash || ''); if (!validPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } const tokens = generateTokens(user); // Remove password from response delete user.password_hash; res.json({ user, tokens }); } catch (error) { console.error('[Login Error]', error.message); res.status(500).json({ error: 'Login failed' }); } } /** * Google OAuth login */ async function googleLogin(req, res) { const { accessToken } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { // Verify Google token and get profile const profile = await googleService.verifyToken(accessToken); if (!profile || !profile.email) { return res.status(401).json({ error: 'Invalid Google token' }); } // Find or create user const existingResult = await pool.query('SELECT * FROM users WHERE email = $1', [profile.email]); let user; if (existingResult.rows.length === 0) { // Create new user from Google profile const query = ` INSERT INTO users (name, email, avatar, google_id, role, auth_provider) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, email, avatar, role, created_at `; const newUserResult = await pool.query(query, [ profile.name || 'Google User', profile.email, profile.picture || null, profile.sub, 'tourist', 'google' ]); user = newUserResult.rows[0]; } else { user = existingResult.rows[0]; delete user.password_hash; } const tokens = generateTokens(user); res.json({ user, tokens }); } catch (error) { console.error('[Google Login Error]', error.message); res.status(500).json({ error: 'Google login failed' }); } } /** * Refresh tokens */ async function refresh(req, res) { const { refreshToken } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { const decoded = jwt.verify(refreshToken, JWT_SECRET); if (decoded.type !== 'refresh') { return res.status(401).json({ error: 'Invalid refresh token' }); } const result = await pool.query('SELECT id, email, name, role FROM users WHERE id = $1', [decoded.id]); if (result.rows.length === 0) { return res.status(401).json({ error: 'User not found' }); } const user = result.rows[0]; const tokens = generateTokens(user); res.json({ user, tokens }); } catch (error) { res.status(401).json({ error: 'Invalid or expired refresh token' }); } } /** * Get current user */ async function currentUser(req, res) { const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { const result = await pool.query('SELECT id, email, name, avatar, role, created_at FROM users WHERE id = $1', [req.user.id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } res.json({ user: result.rows[0] }); } catch (error) { console.error('[CurrentUser Error]', error.message); res.status(500).json({ error: 'Failed to fetch user' }); } } /** * Request password reset - generates token and stores in DB * In production, send email with reset link */ async function forgotPassword(req, res) { const { email } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); try { const result = await pool.query('SELECT id FROM users WHERE email = $1', [email]); // Always return success to prevent email enumeration if (result.rows.length === 0) { return res.json({ success: true, message: 'If a user exists with this email, a reset link will be sent.' }); } const userId = result.rows[0].id; const crypto = require('crypto'); const resetToken = crypto.randomBytes(32).toString('hex'); const hashedToken = await bcrypt.hash(resetToken, 10); const expiresAt = new Date(Date.now() + 3600000); // 1 hour // Store reset token in database (create table if needed) await pool.query(` CREATE TABLE IF NOT EXISTS password_reset_tokens ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, used BOOLEAN DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ) `); // Delete old tokens for this user await pool.query('DELETE FROM password_reset_tokens WHERE user_id = $1', [userId]); // Insert new token await pool.query( 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [userId, hashedToken, expiresAt] ); // TODO: Send email with reset link containing resetToken console.log(`[ForgotPassword] Reset token generated for ${email}. Token: ${resetToken}`); res.json({ success: true, message: 'If a user exists with this email, a reset link will be sent.', // DEV ONLY - remove in production: _devToken: process.env.NODE_ENV !== 'production' ? resetToken : undefined }); } catch (error) { console.error('[ForgotPassword Error]', error.message); res.status(500).json({ error: 'Password reset request failed' }); } } /** * Reset password using token */ async function resetPassword(req, res) { const { token, email, newPassword } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); if (!token || !email || !newPassword) { return res.status(400).json({ error: 'Token, email, and new password are required' }); } try { // Find user and their reset token const userResult = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if (userResult.rows.length === 0) { return res.status(400).json({ error: 'Invalid or expired reset token' }); } const userId = userResult.rows[0].id; const tokenResult = await pool.query( 'SELECT token_hash, expires_at FROM password_reset_tokens WHERE user_id = $1 AND used = false', [userId] ); if (tokenResult.rows.length === 0) { return res.status(400).json({ error: 'Invalid or expired reset token' }); } const { token_hash, expires_at } = tokenResult.rows[0]; // Check expiry if (new Date() > new Date(expires_at)) { return res.status(400).json({ error: 'Reset token has expired' }); } // Verify token const validToken = await bcrypt.compare(token, token_hash); if (!validToken) { return res.status(400).json({ error: 'Invalid or expired reset token' }); } // Update password const hashedPassword = await bcrypt.hash(newPassword, 12); await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [hashedPassword, userId]); // Mark token as used await pool.query('UPDATE password_reset_tokens SET used = true WHERE user_id = $1', [userId]); res.json({ success: true, message: 'Password has been reset successfully' }); } catch (error) { console.error('[ResetPassword Error]', error.message); res.status(500).json({ error: 'Password reset failed' }); } } /** * Change password (authenticated) */ async function changePassword(req, res) { const { currentPassword, newPassword } = req.body; const pool = getPool(); if (!pool) return res.status(500).json({ error: 'Database not connected' }); if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'Current password and new password are required' }); } try { const userId = req.user.id || req.user.sub; const result = await pool.query('SELECT password_hash FROM users WHERE id = $1', [userId]); if (result.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } const validPassword = await bcrypt.compare(currentPassword, result.rows[0].password_hash || ''); if (!validPassword) { return res.status(401).json({ error: 'Current password is incorrect' }); } const hashedPassword = await bcrypt.hash(newPassword, 12); await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [hashedPassword, userId]); res.json({ success: true, message: 'Password changed successfully' }); } catch (error) { console.error('[ChangePassword Error]', error.message); res.status(500).json({ error: 'Password change failed' }); } } /** * Logout - For stateless JWT, just acknowledge (client removes token) * Could implement token blacklist if needed */ async function logout(req, res) { // Stateless JWT - just acknowledge // For actual token invalidation, implement a blacklist in Redis/DB res.json({ success: true, message: 'Logged out successfully' }); } module.exports = { register, sendOtp, login, googleLogin, refresh, currentUser, forgotPassword, resetPassword, changePassword, logout };