diff --git a/server/package-lock.json b/server/package-lock.json index e97d1f1..3f79208 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -27,7 +27,8 @@ "passport-google-oauth20": "^2.0.0", "qs": "6.14.1", "redis": "^5.8.3", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "zod": "^4.3.5" }, "devDependencies": { "@flydotio/dockerfile": "^0.7.10", @@ -3105,6 +3106,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4582,6 +4584,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/server/package.json b/server/package.json index 47df894..08a0269 100644 --- a/server/package.json +++ b/server/package.json @@ -31,7 +31,8 @@ "passport-google-oauth20": "^2.0.0", "qs": "6.14.1", "redis": "^5.8.3", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "zod": "^4.3.5" }, "devDependencies": { "@flydotio/dockerfile": "^0.7.10", diff --git a/server/routes/auth.js b/server/routes/auth.js index 6eb2a55..445734d 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -3,8 +3,10 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import prisma from "../db.js"; import { generateOTP, sendOTPEmail, isOTPEnabled } from "../utils/email.js"; +import { signupSchema, resetPasswordSchema } from "../schemas/auth.schema. js"; import passport from "../config/passport.js"; + const router = express.Router(); // Generate JWT token @@ -141,23 +143,14 @@ router.post("/signup", async (req, res) => { console.log("🔐 Signup attempt:", { username, email }); - if (!username || !email || !password) { - return res - .status(400) - .json({ error: "Username, email, and password are required" }); - } - - if (username.length < 3) { - return res - .status(400) - .json({ error: "Username must be at least 3 characters" }); - } - - if (password.length < 6) { - return res - .status(400) - .json({ error: "Password must be at least 6 characters long" }); - } + // Validate request body with Zod schema +try { + signupSchema.parse({ username, email, password, otp }); +} catch (zodError) { + // Format Zod errors into user-friendly messages + const errors = zodError.errors.map(err => err.message).join('; '); + return res.status(400).json({ error: errors }); +} // Verify OTP if enabled if (isOTPEnabled()) { @@ -342,17 +335,14 @@ router.post("/reset-password", async (req, res) => { try { const { email, otp, newPassword } = req.body; - if (!email || !otp || !newPassword) { - return res - .status(400) - .json({ error: "Email, OTP, and new password are required" }); - } - - if (newPassword.length < 6) { - return res - .status(400) - .json({ error: "Password must be at least 6 characters long" }); - } + // Validate request body with Zod schema +try { + resetPasswordSchema.parse({ email, otp, newPassword }); +} catch (zodError) { + // Format Zod errors into user-friendly messages + const errors = zodError.errors.map(err => err.message).join('; '); + return res.status(400).json({ error: errors }); +} // Verify OTP const otpRecord = await prisma.otp.findFirst({ diff --git a/server/schemas/auth.schema.js b/server/schemas/auth.schema.js new file mode 100644 index 0000000..cd93e64 --- /dev/null +++ b/server/schemas/auth.schema.js @@ -0,0 +1,85 @@ +import { z } from 'zod'; + +/** + * Password validation schema using Zod + * Aligned with frontend PasswordInput component from PR #85 + * + * Criteria (matching frontend strength calculator): + * - Minimum 8 characters + * - At least one uppercase letter + * - At least one lowercase letter + * - At least one number + * - At least one special character + */ +export const passwordSchema = z + .string() + .min(8, 'Password must be at least 8 characters long') + .regex(/[a-z]/, 'Password must include at least one lowercase letter') + .regex(/[A-Z]/, 'Password must include at least one uppercase letter') + .regex(/[0-9]/, 'Password must include at least one number') + .regex( + /[^A-Za-z0-9]/, + 'Password must include at least one special character (! @#$%^&*... )' + ) + .refine( + (password) => { + // Block common passwords + const commonPasswords = [ + 'password', 'password123', '123456', '123456789', '12345678', + 'qwerty', 'abc123', 'monkey', '1234567', 'letmein', + 'trustno1', 'dragon', 'baseball', 'iloveyou', 'master', + 'sunshine', 'ashley', 'bailey', 'shadow', 'superman', + 'qazwsx', 'welcome', 'admin', 'login', 'passw0rd' + ]; + return !commonPasswords.includes(password.toLowerCase()); + }, + { message: 'This password is too common. Please choose a more secure password' } + ) + .refine( + (password) => { + // Block sequential characters (abc, 123) + const sequential = /(abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i; + return !sequential.test(password); + }, + { message: 'Password should not contain sequential characters (e.g., "abc", "123")' } + ) + .refine( + (password) => { + // Block repeated characters (aaa, 111) + return !/(. )\1{2,}/.test(password); + }, + { message: 'Password should not contain repeated characters (e.g., "aaa", "111")' } + ); + +/** + * Signup request validation schema + */ +export const signupSchema = z. object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(30, 'Username must not exceed 30 characters') + .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), + email: z + . string() + .email('Please enter a valid email address'), + password: passwordSchema, + otp: z.string().optional() +}); + +/** + * Reset password request validation schema + */ +export const resetPasswordSchema = z. object({ + email: z. string().email('Please enter a valid email address'), + otp: z.string().min(6, 'OTP must be 6 digits'), + newPassword: passwordSchema +}); + +/** + * Login request validation schema + */ +export const loginSchema = z.object({ + usernameOrEmail: z.string().min(1, 'Username or email is required'), + password: z.string().min(1, 'Password is required') +}); \ No newline at end of file