webupdate
This commit is contained in:
662
backend/routes/customer-auth.js
Normal file
662
backend/routes/customer-auth.js
Normal file
@@ -0,0 +1,662 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user