File size: 14,552 Bytes
bae92ef
 
 
 
 
 
 
db00c33
bae92ef
e6664ed
bae92ef
 
 
e6664ed
 
 
 
 
 
 
 
 
 
bae92ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
db00c33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bae92ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3ef35d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bae92ef
 
db00c33
bae92ef
 
 
d3ef35d
 
 
 
 
bae92ef
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
/**
 * 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
};