← 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
};