← back to Watches

middleware/auth.js

337 lines

/**
 * Authentication Middleware
 * Session validation, user loading, and route protection
 */

import pool from '../database/connection.js';

/**
 * Load user from session token
 * Adds req.user if valid session exists
 */
export async function loadUser(req, res, next) {
  try {
    const sessionToken = req.cookies?.session_token;

    if (!sessionToken) {
      req.user = null;
      return next();
    }

    // Validate session and get user
    const result = await pool.query(
      `SELECT u.id, u.email, u.name, u.avatar_url, u.role, u.provider,
              u.email_verified, u.created_at
       FROM users u
       JOIN user_sessions s ON u.id = s.user_id
       WHERE s.session_token = $1
         AND s.is_valid = true
         AND s.expires_at > NOW()
         AND u.is_active = true`,
      [sessionToken]
    );

    if (result.rows.length === 0) {
      // Invalid or expired session - clear cookie
      res.clearCookie('session_token');
      req.user = null;
      return next();
    }

    // Update session last_active_at
    await pool.query(
      `UPDATE user_sessions SET last_active_at = NOW() WHERE session_token = $1`,
      [sessionToken]
    );

    req.user = result.rows[0];
    next();

  } catch (error) {
    console.error('Auth middleware error:', error);
    req.user = null;
    next();
  }
}

/**
 * Require authentication
 * Returns 401 if not authenticated
 */
export function requireAuth(req, res, next) {
  if (!req.user) {
    return res.status(401).json({
      success: false,
      error: 'Authentication required',
      code: 'AUTH_REQUIRED'
    });
  }
  next();
}

/**
 * Require specific role
 * @param {string[]} roles - Allowed roles
 */
export function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: 'Authentication required',
        code: 'AUTH_REQUIRED'
      });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: 'Insufficient permissions',
        code: 'FORBIDDEN'
      });
    }

    next();
  };
}

/**
 * Require admin role
 */
export const requireAdmin = requireRole('admin');

/**
 * Optional auth - loads user if present but doesn't require it
 */
export function optionalAuth(req, res, next) {
  // loadUser already handles this, just continue
  next();
}

/**
 * Logout - invalidate session
 */
export async function logout(req, res) {
  try {
    const sessionToken = req.cookies?.session_token;

    if (sessionToken) {
      // Invalidate session in database
      await pool.query(
        `UPDATE user_sessions
         SET is_valid = false, revoked_at = NOW(), revoke_reason = 'logout'
         WHERE session_token = $1`,
        [sessionToken]
      );
    }

    // Clear cookie
    res.clearCookie('session_token');

    res.json({ success: true, message: 'Logged out successfully' });

  } catch (error) {
    console.error('Logout error:', error);
    res.status(500).json({ success: false, error: 'Logout failed' });
  }
}

/**
 * Logout all sessions for a user
 */
export async function logoutAll(req, res) {
  try {
    if (!req.user) {
      return res.status(401).json({ success: false, error: 'Not authenticated' });
    }

    // Invalidate all sessions
    await pool.query(
      `UPDATE user_sessions
       SET is_valid = false, revoked_at = NOW(), revoke_reason = 'logout_all'
       WHERE user_id = $1 AND is_valid = true`,
      [req.user.id]
    );

    // Clear current cookie
    res.clearCookie('session_token');

    res.json({ success: true, message: 'All sessions logged out' });

  } catch (error) {
    console.error('Logout all error:', error);
    res.status(500).json({ success: false, error: 'Logout failed' });
  }
}

/**
 * Refresh session token
 */
export async function refreshSession(req, res) {
  try {
    const refreshToken = req.body.refreshToken;

    if (!refreshToken) {
      return res.status(400).json({ success: false, error: 'Refresh token required' });
    }

    // Find valid refresh token
    const result = await pool.query(
      `SELECT s.*, u.id as user_id, u.email, u.name, u.avatar_url
       FROM user_sessions s
       JOIN users u ON s.user_id = u.id
       WHERE s.refresh_token = $1
         AND s.is_valid = true
         AND s.refresh_expires_at > NOW()
         AND u.is_active = true`,
      [refreshToken]
    );

    if (result.rows.length === 0) {
      return res.status(401).json({ success: false, error: 'Invalid refresh token' });
    }

    const session = result.rows[0];

    // Generate new session token
    const { v4: uuidv4 } = await import('uuid');
    const newSessionToken = uuidv4();
    const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

    // Update session
    await pool.query(
      `UPDATE user_sessions
       SET session_token = $1, expires_at = $2, last_active_at = NOW()
       WHERE id = $3`,
      [newSessionToken, newExpiresAt, session.id]
    );

    // Set new cookie
    res.cookie('session_token', newSessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      sameSite: 'lax'
    });

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

  } catch (error) {
    console.error('Refresh session error:', error);
    res.status(500).json({ success: false, error: 'Session refresh failed' });
  }
}

/**
 * Get current user
 */
export function getCurrentUser(req, res) {
  if (!req.user) {
    return res.json({ success: true, user: null });
  }

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

/**
 * Get user's active sessions
 */
export async function getSessions(req, res) {
  try {
    if (!req.user) {
      return res.status(401).json({ success: false, error: 'Not authenticated' });
    }

    const result = await pool.query(
      `SELECT id, user_agent, ip_address, device_type, created_at, last_active_at,
              session_token = $2 as is_current
       FROM user_sessions
       WHERE user_id = $1 AND is_valid = true AND expires_at > NOW()
       ORDER BY last_active_at DESC`,
      [req.user.id, req.cookies?.session_token]
    );

    res.json({
      success: true,
      sessions: result.rows.map(s => ({
        id: s.id,
        device: s.device_type,
        userAgent: s.user_agent,
        ip: s.ip_address,
        createdAt: s.created_at,
        lastActive: s.last_active_at,
        isCurrent: s.is_current
      }))
    });

  } catch (error) {
    console.error('Get sessions error:', error);
    res.status(500).json({ success: false, error: 'Failed to get sessions' });
  }
}

/**
 * Revoke a specific session
 */
export async function revokeSession(req, res) {
  try {
    if (!req.user) {
      return res.status(401).json({ success: false, error: 'Not authenticated' });
    }

    const { sessionId } = req.params;

    // Only allow revoking own sessions
    const result = await pool.query(
      `UPDATE user_sessions
       SET is_valid = false, revoked_at = NOW(), revoke_reason = 'user_revoked'
       WHERE id = $1 AND user_id = $2 AND is_valid = true
       RETURNING id`,
      [sessionId, req.user.id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ success: false, error: 'Session not found' });
    }

    res.json({ success: true, message: 'Session revoked' });

  } catch (error) {
    console.error('Revoke session error:', error);
    res.status(500).json({ success: false, error: 'Failed to revoke session' });
  }
}

export default {
  loadUser,
  requireAuth,
  requireRole,
  requireAdmin,
  optionalAuth,
  logout,
  logoutAll,
  refreshSession,
  getCurrentUser,
  getSessions,
  revokeSession
};