Files
rentall-app/backend/middleware/validation.js
jackiettran cf97dffbfb MFA
2026-01-16 18:04:39 -05:00

392 lines
9.7 KiB
JavaScript

const { body, query, validationResult } = require("express-validator");
const DOMPurify = require("dompurify");
const { JSDOM } = require("jsdom");
// Create a DOM purify instance for server-side sanitization
const window = new JSDOM("").window;
const purify = DOMPurify(window);
// Password strength validation
const passwordStrengthRegex =
/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[-@$!%*?&#^]).{8,}$/; //-@$!%*?&#^
const commonPasswords = [
"password",
"123456",
"123456789",
"qwerty",
"abc123",
"password123",
"admin",
"letmein",
"welcome",
"monkey",
"1234567890",
];
// Sanitization middleware
const sanitizeInput = (req, res, next) => {
const sanitizeValue = (value) => {
if (typeof value === "string") {
return purify.sanitize(value, { ALLOWED_TAGS: [] });
}
if (typeof value === "object" && value !== null) {
const sanitized = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = sanitizeValue(val);
}
return sanitized;
}
return value;
};
if (req.body) {
req.body = sanitizeValue(req.body);
}
if (req.query) {
req.query = sanitizeValue(req.query);
}
if (req.params) {
req.params = sanitizeValue(req.params);
}
next();
};
// Validation error handler
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: "Validation failed",
details: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
};
// Registration validation rules
const validateRegistration = [
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email address")
.isLength({ max: 255 })
.withMessage("Email must be less than 255 characters"),
body("password")
.isLength({ min: 8, max: 128 })
.withMessage("Password must be between 8 and 128 characters")
.matches(passwordStrengthRegex)
.withMessage(
"Password does not meet requirements"
)
.custom((value) => {
if (commonPasswords.includes(value.toLowerCase())) {
throw new Error(
"Password is too common. Please choose a stronger password"
);
}
return true;
}),
body("firstName")
.trim()
.isLength({ min: 1, max: 50 })
.withMessage("First name must be between 1 and 50 characters")
.matches(/^[a-zA-Z\s\-']+$/)
.withMessage(
"First name can only contain letters, spaces, hyphens, and apostrophes"
),
body("lastName")
.trim()
.isLength({ min: 1, max: 50 })
.withMessage("Last name must be between 1 and 50 characters")
.matches(/^[a-zA-Z\s\-']+$/)
.withMessage(
"Last name can only contain letters, spaces, hyphens, and apostrophes"
),
body("phone")
.optional()
.isMobilePhone()
.withMessage("Please provide a valid phone number"),
handleValidationErrors,
];
// Login validation rules
const validateLogin = [
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email address"),
body("password")
.notEmpty()
.withMessage("Password is required")
.isLength({ max: 128 })
.withMessage("Password is too long"),
handleValidationErrors,
];
// Google auth validation
const validateGoogleAuth = [
body("code")
.notEmpty()
.withMessage("Authorization code is required")
.isLength({ max: 512 })
.withMessage("Invalid authorization code format"),
handleValidationErrors,
];
// Profile update validation
const validateProfileUpdate = [
body("firstName")
.optional()
.trim()
.isLength({ min: 1, max: 50 })
.withMessage("First name must be between 1 and 50 characters")
.matches(/^[a-zA-Z\s\-']+$/)
.withMessage(
"First name can only contain letters, spaces, hyphens, and apostrophes"
),
body("lastName")
.optional()
.trim()
.isLength({ min: 1, max: 50 })
.withMessage("Last name must be between 1 and 50 characters")
.matches(/^[a-zA-Z\s\-']+$/)
.withMessage(
"Last name can only contain letters, spaces, hyphens, and apostrophes"
),
body("phone")
.optional()
.isMobilePhone()
.withMessage("Please provide a valid phone number"),
body("address1")
.optional()
.trim()
.isLength({ max: 255 })
.withMessage("Address line 1 must be less than 255 characters"),
body("address2")
.optional()
.trim()
.isLength({ max: 255 })
.withMessage("Address line 2 must be less than 255 characters"),
body("city")
.optional()
.trim()
.isLength({ max: 100 })
.withMessage("City must be less than 100 characters")
.matches(/^[a-zA-Z\s\-']+$/)
.withMessage(
"City can only contain letters, spaces, hyphens, and apostrophes"
),
body("state")
.optional()
.trim()
.isLength({ max: 100 })
.withMessage("State must be less than 100 characters"),
body("zipCode")
.optional()
.trim()
.matches(/^[0-9]{5}(-[0-9]{4})?$/)
.withMessage("Please provide a valid ZIP code"),
body("country")
.optional()
.trim()
.isLength({ max: 100 })
.withMessage("Country must be less than 100 characters"),
handleValidationErrors,
];
// Password change validation
const validatePasswordChange = [
body("currentPassword")
.notEmpty()
.withMessage("Current password is required"),
body("newPassword")
.isLength({ min: 8, max: 128 })
.withMessage("New password must be between 8 and 128 characters")
.matches(passwordStrengthRegex)
.withMessage(
"New password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"
)
.custom((value, { req }) => {
if (value === req.body.currentPassword) {
throw new Error("New password must be different from current password");
}
if (commonPasswords.includes(value.toLowerCase())) {
throw new Error(
"Password is too common. Please choose a stronger password"
);
}
return true;
}),
body("confirmPassword").custom((value, { req }) => {
if (value !== req.body.newPassword) {
throw new Error("Password confirmation does not match");
}
return true;
}),
handleValidationErrors,
];
// Forgot password validation
const validateForgotPassword = [
body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email address")
.isLength({ max: 255 })
.withMessage("Email must be less than 255 characters"),
handleValidationErrors,
];
// Reset password validation
const validateResetPassword = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
body("newPassword")
.isLength({ min: 8, max: 128 })
.withMessage("Password must be between 8 and 128 characters")
.matches(passwordStrengthRegex)
.withMessage(
"Password does not meet requirements"
)
.custom((value) => {
if (commonPasswords.includes(value.toLowerCase())) {
throw new Error(
"Password is too common. Please choose a stronger password"
);
}
return true;
}),
handleValidationErrors,
];
// Verify reset token validation
const validateVerifyResetToken = [
body("token")
.notEmpty()
.withMessage("Reset token is required")
.isLength({ min: 64, max: 64 })
.withMessage("Invalid reset token format"),
handleValidationErrors,
];
// Feedback validation
const validateFeedback = [
body("feedbackText")
.trim()
.isLength({ min: 5, max: 5000 })
.withMessage("Feedback must be between 5 and 5000 characters"),
body("url")
.optional()
.trim()
.isLength({ max: 500 })
.withMessage("URL must be less than 500 characters"),
handleValidationErrors,
];
// Coordinate validation for query parameters (e.g., location search)
const validateCoordinatesQuery = [
query("lat")
.optional()
.isFloat({ min: -90, max: 90 })
.withMessage("Latitude must be between -90 and 90"),
query("lng")
.optional()
.isFloat({ min: -180, max: 180 })
.withMessage("Longitude must be between -180 and 180"),
query("radius")
.optional()
.isFloat({ min: 0.1, max: 100 })
.withMessage("Radius must be between 0.1 and 100 miles"),
handleValidationErrors,
];
// Coordinate validation for body parameters (e.g., user addresses, forum posts)
const validateCoordinatesBody = [
body("latitude")
.optional()
.isFloat({ min: -90, max: 90 })
.withMessage("Latitude must be between -90 and 90"),
body("longitude")
.optional()
.isFloat({ min: -180, max: 180 })
.withMessage("Longitude must be between -180 and 180"),
];
// Two-Factor Authentication validation
const validateTotpCode = [
body("code")
.trim()
.matches(/^\d{6}$/)
.withMessage("TOTP code must be exactly 6 digits"),
handleValidationErrors,
];
const validateEmailOtp = [
body("code")
.trim()
.matches(/^\d{6}$/)
.withMessage("Email OTP must be exactly 6 digits"),
handleValidationErrors,
];
const validateRecoveryCode = [
body("code")
.trim()
.matches(/^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i)
.withMessage("Recovery code must be in format XXXX-XXXX"),
handleValidationErrors,
];
module.exports = {
sanitizeInput,
handleValidationErrors,
validateRegistration,
validateLogin,
validateGoogleAuth,
validateProfileUpdate,
validatePasswordChange,
validateForgotPassword,
validateResetPassword,
validateVerifyResetToken,
validateFeedback,
validateCoordinatesQuery,
validateCoordinatesBody,
// Two-Factor Authentication
validateTotpCode,
validateEmailOtp,
validateRecoveryCode,
};