663 lines
19 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
};
|
|
|
|
// 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;
|