Files
SkyArtShop/backend/routes/customer-auth.js
Local Server 2a2a3d99e5 webupdate
2026-01-18 02:22:05 -06:00

663 lines
19 KiB
JavaScript

const express = require("express");
const router = express.Router();
const bcrypt = require("bcrypt");
const nodemailer = require("nodemailer");
const { pool } = require("../config/database");
const logger = require("../config/logger");
const rateLimit = require("express-rate-limit");
// SECURITY: Rate limiting for auth endpoints to prevent brute force
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 failed attempts per window before lockout
skipSuccessfulRequests: true, // Only count failed attempts
message: {
success: false,
message:
"Too many failed login attempts, please try again after 15 minutes",
},
standardHeaders: true,
legacyHeaders: false,
});
const signupRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 signups per hour per IP (increased for production)
message: {
success: false,
message: "Too many signup attempts, please try again later",
},
});
const resendCodeLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 3, // 3 resends per minute
message: {
success: false,
message: "Please wait before requesting another code",
},
});
// SECURITY: HTML escape function to prevent XSS in emails
const escapeHtml = (str) => {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
// SECURITY: Use crypto for secure verification code generation
const crypto = require("crypto");
// Email transporter configuration
let transporter = null;
if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
// Generate 6-digit verification code using cryptographically secure random
function generateVerificationCode() {
// SECURITY: Use crypto.randomInt for secure random number generation
return crypto.randomInt(100000, 999999).toString();
}
// Send verification email
async function sendVerificationEmail(email, code, firstName) {
if (!transporter) {
logger.warn("SMTP not configured - verification code logged instead");
logger.info(`🔐 Verification code for ${email}: ${code}`);
return true;
}
// SECURITY: Escape user input to prevent XSS in emails
const safeName = escapeHtml(firstName) || "there";
const safeCode = String(code).replace(/[^0-9]/g, ""); // Only allow digits
const mailOptions = {
from: process.env.SMTP_FROM || '"Sky Art Shop" <noreply@skyartshop.com>',
to: email,
subject: "Verify your Sky Art Shop account",
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #e91e63 0%, #9c27b0 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.code { font-size: 36px; font-weight: bold; color: #e91e63; letter-spacing: 8px; text-align: center; padding: 20px; background: white; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎨 Sky Art Shop</h1>
<p>Welcome to our creative community!</p>
</div>
<div class="content">
<p>Hi ${safeName}!</p>
<p>Thank you for creating an account with Sky Art Shop. Please use the verification code below to complete your registration:</p>
<div class="code">${safeCode}</div>
<p>This code will expire in <strong>15 minutes</strong>.</p>
<p>If you didn't create this account, please ignore this email.</p>
</div>
<div class="footer">
<p>© 2025 Sky Art Shop. All rights reserved.</p>
<p>Your one-stop shop for scrapbooking, journaling, and creative stationery.</p>
</div>
</div>
</body>
</html>
`,
};
try {
await transporter.sendMail(mailOptions);
logger.info(`Verification email sent to ${email}`);
return true;
} catch (error) {
logger.error("Error sending verification email:", error);
// Still log code as fallback
logger.info(`🔐 Verification code for ${email}: ${code}`);
return false;
}
}
// Customer auth middleware - session based
const requireCustomerAuth = (req, res, next) => {
if (!req.session || !req.session.customerId) {
return res
.status(401)
.json({ success: false, message: "Please login to continue" });
}
next();
};
// ===========================
// SIGNUP - Create new customer
// ===========================
router.post("/signup", signupRateLimiter, async (req, res) => {
try {
const {
firstName,
lastName,
email,
password,
newsletterSubscribed = false,
} = req.body;
// Validation
if (!firstName || !lastName || !email || !password) {
return res.status(400).json({
success: false,
message:
"All fields are required (firstName, lastName, email, password)",
});
}
// SECURITY: Sanitize inputs to prevent XSS
const sanitizedFirstName = firstName
.replace(/[<>"'&]/g, "")
.trim()
.substring(0, 50);
const sanitizedLastName = lastName
.replace(/[<>"'&]/g, "")
.trim()
.substring(0, 50);
// Email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: "Please enter a valid email address",
});
}
// Password strength validation
if (password.length < 8) {
return res.status(400).json({
success: false,
message: "Password must be at least 8 characters long",
});
}
// Check if email already exists
const existingCustomer = await pool.query(
"SELECT id, email_verified FROM customers WHERE email = $1",
[email.toLowerCase()],
);
if (existingCustomer.rows.length > 0) {
const customer = existingCustomer.rows[0];
// If email exists but not verified, allow re-registration
if (!customer.email_verified) {
// Generate new verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE customers
SET first_name = $1, last_name = $2, password_hash = $3,
verification_code = $4, verification_code_expires = $5,
updated_at = CURRENT_TIMESTAMP
WHERE email = $6`,
[
firstName,
lastName,
passwordHash,
verificationCode,
expiresAt,
email.toLowerCase(),
],
);
// Send verification email
await sendVerificationEmail(email, verificationCode, firstName);
// Store email in session for verification step
req.session.pendingVerificationEmail = email.toLowerCase();
return res.json({
success: true,
message:
"Verification code sent to your email. Please check your inbox.",
requiresVerification: true,
});
}
return res.status(400).json({
success: false,
message:
"An account with this email already exists. Please login instead.",
});
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Generate verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
// Create customer
await pool.query(
`INSERT INTO customers (first_name, last_name, email, password_hash, verification_code, verification_code_expires, newsletter_subscribed)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email`,
[
firstName,
lastName,
email.toLowerCase(),
passwordHash,
verificationCode,
expiresAt,
newsletterSubscribed,
],
);
// Send verification email
await sendVerificationEmail(email, verificationCode, firstName);
// Store email in session for verification step
req.session.pendingVerificationEmail = email.toLowerCase();
logger.info(`New customer signup: ${email}`);
res.json({
success: true,
message:
"Account created! Please check your email for the verification code.",
requiresVerification: true,
});
} catch (error) {
logger.error("Signup error:", error);
res.status(500).json({
success: false,
message: "Failed to create account. Please try again.",
});
}
});
// ===========================
// VERIFY EMAIL
// ===========================
router.post("/verify-email", async (req, res) => {
try {
const { email, code } = req.body;
const emailToVerify =
email?.toLowerCase() || req.session.pendingVerificationEmail;
if (!emailToVerify || !code) {
return res.status(400).json({
success: false,
message: "Email and verification code are required",
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email_verified, verification_code, verification_code_expires
FROM customers WHERE email = $1`,
[emailToVerify],
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: "Account not found. Please sign up first.",
});
}
const customer = result.rows[0];
if (customer.email_verified) {
return res.json({
success: true,
message: "Email already verified. You can now login.",
alreadyVerified: true,
});
}
if (customer.verification_code !== code) {
return res.status(400).json({
success: false,
message: "Invalid verification code. Please try again.",
});
}
if (new Date() > new Date(customer.verification_code_expires)) {
return res.status(400).json({
success: false,
message: "Verification code has expired. Please request a new one.",
expired: true,
});
}
// Mark email as verified
await pool.query(
`UPDATE customers
SET email_verified = TRUE, verification_code = NULL, verification_code_expires = NULL,
last_login = CURRENT_TIMESTAMP, login_count = 1
WHERE id = $1`,
[customer.id],
);
// Set session - auto-login after verification
req.session.customerId = customer.id;
req.session.customerEmail = emailToVerify;
req.session.customerName = customer.first_name;
delete req.session.pendingVerificationEmail;
logger.info(`Email verified for customer: ${emailToVerify}`);
res.json({
success: true,
message: "Email verified successfully! You are now logged in.",
customer: {
firstName: customer.first_name,
lastName: customer.last_name,
email: emailToVerify,
},
});
} catch (error) {
logger.error("Email verification error:", error);
res.status(500).json({
success: false,
message: "Verification failed. Please try again.",
});
}
});
// ===========================
// RESEND VERIFICATION CODE
// ===========================
router.post("/resend-code", resendCodeLimiter, async (req, res) => {
try {
const { email } = req.body;
const emailToVerify =
email?.toLowerCase() || req.session.pendingVerificationEmail;
if (!emailToVerify) {
return res.status(400).json({
success: false,
message: "Email is required",
});
}
const result = await pool.query(
"SELECT id, first_name, email_verified FROM customers WHERE email = $1",
[emailToVerify],
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: "Account not found. Please sign up first.",
});
}
const customer = result.rows[0];
if (customer.email_verified) {
return res.json({
success: true,
message: "Email already verified. You can now login.",
alreadyVerified: true,
});
}
// Generate new verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await pool.query(
`UPDATE customers
SET verification_code = $1, verification_code_expires = $2
WHERE id = $3`,
[verificationCode, expiresAt, customer.id],
);
// Send verification email
await sendVerificationEmail(
emailToVerify,
verificationCode,
customer.first_name,
);
res.json({
success: true,
message: "New verification code sent to your email.",
});
} catch (error) {
logger.error("Resend code error:", error);
res.status(500).json({
success: false,
message: "Failed to resend code. Please try again.",
});
}
});
// ===========================
// LOGIN
// ===========================
router.post("/login", authRateLimiter, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
message: "Email and password are required",
});
}
// SECURITY: Sanitize and validate email format
const sanitizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(sanitizedEmail)) {
// SECURITY: Use consistent timing to prevent enumeration
await bcrypt.hash("dummy-password", 12);
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email, password_hash, email_verified, is_active
FROM customers WHERE email = $1`,
[sanitizedEmail],
);
if (result.rows.length === 0) {
// SECURITY: Perform dummy hash to prevent timing attacks
await bcrypt.hash("dummy-password", 12);
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
const customer = result.rows[0];
if (!customer.is_active) {
return res.status(403).json({
success: false,
message: "This account has been deactivated. Please contact support.",
});
}
if (!customer.email_verified) {
// Store email for verification flow
req.session.pendingVerificationEmail = email.toLowerCase();
return res.status(403).json({
success: false,
message: "Please verify your email before logging in.",
requiresVerification: true,
});
}
// Verify password
const isValidPassword = await bcrypt.compare(
password,
customer.password_hash,
);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
// Update last login
await pool.query(
`UPDATE customers SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = $1`,
[customer.id],
);
// Set session
req.session.customerId = customer.id;
req.session.customerEmail = customer.email;
req.session.customerName = customer.first_name;
logger.info(`Customer login: ${email}`);
res.json({
success: true,
message: "Login successful",
customer: {
id: customer.id,
firstName: customer.first_name,
lastName: customer.last_name,
email: customer.email,
},
});
} catch (error) {
logger.error("Login error:", error);
res.status(500).json({
success: false,
message: "Login failed. Please try again.",
});
}
});
// ===========================
// LOGOUT
// ===========================
router.post("/logout", (req, res) => {
req.session.customerId = null;
req.session.customerEmail = null;
req.session.customerName = null;
res.json({ success: true, message: "Logged out successfully" });
});
// ===========================
// GET CURRENT SESSION
// ===========================
router.get("/session", async (req, res) => {
try {
if (!req.session.customerId) {
return res.json({
success: true,
loggedIn: false,
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email, newsletter_subscribed, created_at
FROM customers WHERE id = $1`,
[req.session.customerId],
);
if (result.rows.length === 0) {
req.session.customerId = null;
return res.json({
success: true,
loggedIn: false,
});
}
const customer = result.rows[0];
res.json({
success: true,
loggedIn: true,
customer: {
id: customer.id,
firstName: customer.first_name,
lastName: customer.last_name,
email: customer.email,
newsletterSubscribed: customer.newsletter_subscribed,
memberSince: customer.created_at,
},
});
} catch (error) {
logger.error("Session error:", error);
res.status(500).json({ success: false, message: "Failed to get session" });
}
});
// ===========================
// UPDATE PROFILE
// ===========================
router.put("/profile", requireCustomerAuth, async (req, res) => {
try {
const { firstName, lastName, newsletterSubscribed } = req.body;
const updates = [];
const values = [];
let paramIndex = 1;
if (firstName !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(firstName);
}
if (lastName !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(lastName);
}
if (newsletterSubscribed !== undefined) {
updates.push(`newsletter_subscribed = $${paramIndex++}`);
values.push(newsletterSubscribed);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "No fields to update",
});
}
values.push(req.session.customerId);
await pool.query(
`UPDATE customers SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
values,
);
res.json({ success: true, message: "Profile updated successfully" });
} catch (error) {
logger.error("Profile update error:", error);
res
.status(500)
.json({ success: false, message: "Failed to update profile" });
}
});
module.exports = router;
module.exports.requireCustomerAuth = requireCustomerAuth;