← back to Watches

api/auth/email.js

512 lines

/**
 * Email/Password Authentication Handler
 * Implements registration, login, verification, and password reset
 */

import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcryptjs';
import pool from '../../database/connection.js';

const BCRYPT_ROUNDS = 12;
const PASSWORD_MIN_LENGTH = 8;
const SESSION_DURATION_DAYS = 7;
const VERIFICATION_EXPIRY_HOURS = 24;
const RESET_EXPIRY_HOURS = 1;

/**
 * Validate password strength
 * @param {string} password - Password to validate
 * @returns {object} Validation result
 */
export function validatePassword(password) {
  const errors = [];

  if (!password || password.length < PASSWORD_MIN_LENGTH) {
    errors.push(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`);
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain at least one uppercase letter');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('Password must contain at least one number');
  }

  return {
    valid: errors.length === 0,
    errors
  };
}

/**
 * Validate email format
 * @param {string} email - Email to validate
 * @returns {boolean} Is valid
 */
export function validateEmail(email) {
  const emailRegex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
  return emailRegex.test(email);
}

/**
 * Hash password using bcrypt
 * @param {string} password - Plain text password
 * @returns {string} Hashed password
 */
export async function hashPassword(password) {
  return bcrypt.hash(password, BCRYPT_ROUNDS);
}

/**
 * Verify password against hash
 * @param {string} password - Plain text password
 * @param {string} hash - Bcrypt hash
 * @returns {boolean} Is valid
 */
export async function verifyPassword(password, hash) {
  return bcrypt.compare(password, hash);
}

/**
 * Generate verification token
 * @returns {object} Token and expiry
 */
function generateVerificationToken() {
  const token = uuidv4();
  const expires = new Date(Date.now() + VERIFICATION_EXPIRY_HOURS * 60 * 60 * 1000);
  return { token, expires };
}

/**
 * Generate password reset token
 * @returns {object} Token and expiry
 */
function generateResetToken() {
  const token = uuidv4();
  const expires = new Date(Date.now() + RESET_EXPIRY_HOURS * 60 * 60 * 1000);
  return { token, expires };
}

/**
 * Register new user with email/password
 * @param {string} email - User email
 * @param {string} password - User password
 * @param {string} name - User name (optional)
 * @returns {object} User record and verification token
 */
export async function registerUser(email, password, name = null) {
  // Validate email
  if (!validateEmail(email)) {
    throw new Error('Invalid email format');
  }

  // Validate password
  const passwordValidation = validatePassword(password);
  if (!passwordValidation.valid) {
    throw new Error(passwordValidation.errors.join('. '));
  }

  // Check if email already exists
  const existing = await pool.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (existing.rows.length > 0) {
    throw new Error('Email already registered');
  }

  // Hash password
  const passwordHash = await hashPassword(password);

  // Generate verification token
  const { token: verificationToken, expires: verificationExpires } = generateVerificationToken();

  // Create user
  const result = await pool.query(
    `INSERT INTO users (email, name, password_hash, provider, email_verification_token, email_verification_expires)
     VALUES ($1, $2, $3, 'email', $4, $5)
     RETURNING id, email, name, created_at`,
    [email.toLowerCase(), name, passwordHash, verificationToken, verificationExpires]
  );

  return {
    user: result.rows[0],
    verificationToken
  };
}

/**
 * Verify user email
 * @param {string} token - Verification token
 * @returns {object} User record
 */
export async function verifyEmail(token) {
  const result = await pool.query(
    `UPDATE users
     SET email_verified = true,
         email_verification_token = NULL,
         email_verification_expires = NULL
     WHERE email_verification_token = $1
       AND email_verification_expires > NOW()
       AND email_verified = false
     RETURNING id, email, name`,
    [token]
  );

  if (result.rows.length === 0) {
    throw new Error('Invalid or expired verification token');
  }

  return result.rows[0];
}

/**
 * Login with email/password
 * @param {string} email - User email
 * @param {string} password - User password
 * @returns {object} User record
 */
export async function loginUser(email, password) {
  // Find user by email
  const result = await pool.query(
    `SELECT * FROM users WHERE email = $1 AND provider = 'email' AND is_active = true`,
    [email.toLowerCase()]
  );

  if (result.rows.length === 0) {
    throw new Error('Invalid email or password');
  }

  const user = result.rows[0];

  // Verify password
  const isValid = await verifyPassword(password, user.password_hash);
  if (!isValid) {
    throw new Error('Invalid email or password');
  }

  // Check if email is verified
  if (!user.email_verified) {
    throw new Error('Please verify your email before logging in');
  }

  // Update login stats
  await pool.query(
    `UPDATE users SET last_login_at = NOW(), login_count = login_count + 1 WHERE id = $1`,
    [user.id]
  );

  // Remove sensitive data
  delete user.password_hash;
  delete user.email_verification_token;
  delete user.password_reset_token;

  return user;
}

/**
 * Request password reset
 * @param {string} email - User email
 * @returns {object} Reset token (for sending email)
 */
export async function requestPasswordReset(email) {
  // Find user
  const result = await pool.query(
    `SELECT id FROM users WHERE email = $1 AND provider = 'email' AND is_active = true`,
    [email.toLowerCase()]
  );

  // Always return success to prevent email enumeration
  if (result.rows.length === 0) {
    return { success: true, token: null };
  }

  // Generate reset token
  const { token, expires } = generateResetToken();

  // Store token
  await pool.query(
    `UPDATE users SET password_reset_token = $1, password_reset_expires = $2 WHERE id = $3`,
    [token, expires, result.rows[0].id]
  );

  return { success: true, token };
}

/**
 * Reset password with token
 * @param {string} token - Reset token
 * @param {string} newPassword - New password
 * @returns {object} Success result
 */
export async function resetPassword(token, newPassword) {
  // Validate new password
  const passwordValidation = validatePassword(newPassword);
  if (!passwordValidation.valid) {
    throw new Error(passwordValidation.errors.join('. '));
  }

  // Find user with valid token
  const result = await pool.query(
    `SELECT id FROM users
     WHERE password_reset_token = $1 AND password_reset_expires > NOW()`,
    [token]
  );

  if (result.rows.length === 0) {
    throw new Error('Invalid or expired reset token');
  }

  // Hash new password
  const passwordHash = await hashPassword(newPassword);

  // Update password and clear token
  await pool.query(
    `UPDATE users
     SET password_hash = $1, password_reset_token = NULL, password_reset_expires = NULL
     WHERE id = $2`,
    [passwordHash, result.rows[0].id]
  );

  // Invalidate all sessions for security
  await pool.query(
    `UPDATE user_sessions SET is_valid = false, revoked_at = NOW(), revoke_reason = 'password_reset'
     WHERE user_id = $1 AND is_valid = true`,
    [result.rows[0].id]
  );

  return { success: true };
}

/**
 * Change password (when logged in)
 * @param {string} userId - User ID
 * @param {string} currentPassword - Current password
 * @param {string} newPassword - New password
 * @returns {object} Success result
 */
export async function changePassword(userId, currentPassword, newPassword) {
  // Get current password hash
  const result = await pool.query(
    `SELECT password_hash FROM users WHERE id = $1 AND provider = 'email'`,
    [userId]
  );

  if (result.rows.length === 0) {
    throw new Error('User not found');
  }

  // Verify current password
  const isValid = await verifyPassword(currentPassword, result.rows[0].password_hash);
  if (!isValid) {
    throw new Error('Current password is incorrect');
  }

  // Validate new password
  const passwordValidation = validatePassword(newPassword);
  if (!passwordValidation.valid) {
    throw new Error(passwordValidation.errors.join('. '));
  }

  // Hash and update
  const passwordHash = await hashPassword(newPassword);
  await pool.query(
    `UPDATE users SET password_hash = $1 WHERE id = $2`,
    [passwordHash, userId]
  );

  return { success: true };
}

/**
 * Create session for user
 * @param {object} user - User record
 * @param {object} req - Express request
 * @returns {object} Session tokens
 */
export async function createSession(user, req) {
  const sessionToken = uuidv4();
  const refreshToken = uuidv4();
  const expiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000);
  const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);

  const userAgent = req.headers['user-agent'] || '';
  const ipAddress = req.ip || req.connection?.remoteAddress;

  await pool.query(
    `INSERT INTO user_sessions
     (user_id, session_token, refresh_token, user_agent, ip_address, device_type, expires_at, refresh_expires_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
    [user.id, sessionToken, refreshToken, userAgent, ipAddress, 'desktop', expiresAt, refreshExpiresAt]
  );

  return { sessionToken, refreshToken, expiresAt };
}

// ============================================================================
// Express Route Handlers
// ============================================================================

/**
 * POST /api/auth/register
 */
export async function handleRegister(req, res) {
  try {
    const { email, password, name } = req.body;

    const { user, verificationToken } = await registerUser(email, password, name);

    // In production, send verification email here
    // await sendVerificationEmail(email, verificationToken);

    res.status(201).json({
      success: true,
      message: 'Registration successful. Please check your email to verify your account.',
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      },
      // Include token in dev for testing (remove in production)
      ...(process.env.NODE_ENV !== 'production' && { verificationToken })
    });

  } catch (error) {
    res.status(400).json({ success: false, error: error.message });
  }
}

/**
 * GET /api/auth/verify/:token
 */
export async function handleVerifyEmail(req, res) {
  try {
    const { token } = req.params;
    const user = await verifyEmail(token);

    res.redirect('/?verified=true');

  } catch (error) {
    res.redirect('/?verified=false&error=' + encodeURIComponent(error.message));
  }
}

/**
 * POST /api/auth/login
 */
export async function handleLogin(req, res) {
  try {
    const { email, password } = req.body;

    const user = await loginUser(email, password);
    const session = await createSession(user, req);

    res.cookie('session_token', session.sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000,
      sameSite: 'lax'
    });

    res.json({
      success: true,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        avatar_url: user.avatar_url
      }
    });

  } catch (error) {
    res.status(401).json({ success: false, error: error.message });
  }
}

/**
 * POST /api/auth/forgot-password
 */
export async function handleForgotPassword(req, res) {
  try {
    const { email } = req.body;

    const { token } = await requestPasswordReset(email);

    // In production, send reset email here
    // if (token) await sendPasswordResetEmail(email, token);

    res.json({
      success: true,
      message: 'If an account exists with that email, a password reset link has been sent.',
      // Include token in dev for testing (remove in production)
      ...(process.env.NODE_ENV !== 'production' && token && { resetToken: token })
    });

  } catch (error) {
    res.status(400).json({ success: false, error: error.message });
  }
}

/**
 * POST /api/auth/reset-password
 */
export async function handleResetPassword(req, res) {
  try {
    const { token, password } = req.body;

    await resetPassword(token, password);

    res.json({
      success: true,
      message: 'Password reset successful. You can now log in with your new password.'
    });

  } catch (error) {
    res.status(400).json({ success: false, error: error.message });
  }
}

/**
 * POST /api/auth/change-password (authenticated)
 */
export async function handleChangePassword(req, res) {
  try {
    const { currentPassword, newPassword } = req.body;
    const userId = req.user?.id;

    if (!userId) {
      return res.status(401).json({ success: false, error: 'Not authenticated' });
    }

    await changePassword(userId, currentPassword, newPassword);

    res.json({
      success: true,
      message: 'Password changed successfully.'
    });

  } catch (error) {
    res.status(400).json({ success: false, error: error.message });
  }
}

export default {
  validatePassword,
  validateEmail,
  hashPassword,
  verifyPassword,
  registerUser,
  verifyEmail,
  loginUser,
  requestPasswordReset,
  resetPassword,
  changePassword,
  createSession,
  handleRegister,
  handleVerifyEmail,
  handleLogin,
  handleForgotPassword,
  handleResetPassword,
  handleChangePassword
};