-
Notifications
You must be signed in to change notification settings - Fork 68
feat: harden developer auth (JWT expiry, OTP hashing, brute-force pro… #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
042d25e
e14b3ec
d717f23
f117d4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,14 +5,53 @@ const bcrypt = require("bcryptjs"); | |||||||||
| const z = require("zod"); | ||||||||||
| const jwt = require("jsonwebtoken"); | ||||||||||
| const sendOtp = require("../utils/emailService"); | ||||||||||
| const crypto = require("crypto"); | ||||||||||
| const { | ||||||||||
| loginSchema, | ||||||||||
| changePasswordSchema, | ||||||||||
| deleteAccountSchema, | ||||||||||
| onlyEmailSchema | ||||||||||
| onlyEmailSchema, | ||||||||||
| resetPasswordSchema, | ||||||||||
| verifyOtpSchema | ||||||||||
| } = require("../utils/input.validation"); | ||||||||||
|
|
||||||||||
| const JWT_EXPIRES_IN = '7d'; | ||||||||||
| const OTP_MAX_ATTEMPTS = 5; | ||||||||||
|
|
||||||||||
| async function createAndStoreOtp(userId) { | ||||||||||
| const otp = crypto.randomInt(100000, 1000000).toString(); | ||||||||||
|
|
||||||||||
| // Delete any existing OTP for this user | ||||||||||
| await otpSchema.deleteOne({ userId }); | ||||||||||
|
|
||||||||||
| // Hash OTP before storing | ||||||||||
| const salt = await bcrypt.genSalt(10); | ||||||||||
| const hashedOtp = await bcrypt.hash(otp, salt); | ||||||||||
|
|
||||||||||
| await new otpSchema({ userId, otp: hashedOtp }).save(); | ||||||||||
| return otp; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // HELPER: Validate OTP with attempt tracking | ||||||||||
| async function validateOtp(userId, passedOtp) { | ||||||||||
| const otpDoc = await otpSchema.findOne({ userId }); | ||||||||||
| if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." }; | ||||||||||
|
|
||||||||||
| if (otpDoc.attempts >= OTP_MAX_ATTEMPTS) { | ||||||||||
| await otpDoc.deleteOne(); | ||||||||||
| throw { status: 429, message: "Too many incorrect attempts. Please request a new OTP." }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const isMatch = await bcrypt.compare(passedOtp.toString(), otpDoc.otp); | ||||||||||
| if (!isMatch) { | ||||||||||
| otpDoc.attempts += 1; | ||||||||||
| await otpDoc.save(); | ||||||||||
| const remaining = OTP_MAX_ATTEMPTS - otpDoc.attempts; | ||||||||||
| throw { status: 400, message: `Incorrect OTP. ${remaining} attempt(s) remaining.` }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return otpDoc; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| module.exports.register = async (req, res) => { | ||||||||||
| try { | ||||||||||
|
|
@@ -47,19 +86,20 @@ module.exports.login = async (req, res) => { | |||||||||
| const validPass = await bcrypt.compare(password, dev.password); | ||||||||||
| if (!validPass) return res.status(400).json({ error: "Invalid password" }); | ||||||||||
|
|
||||||||||
| const token = jwt.sign({ _id: dev._id, isVerified: dev.isVerified }, process.env.JWT_SECRET); | ||||||||||
| // FIX 1: JWT now expires in 7 days | ||||||||||
| const token = jwt.sign( | ||||||||||
| { _id: dev._id, isVerified: dev.isVerified }, | ||||||||||
| process.env.JWT_SECRET, | ||||||||||
| { expiresIn: JWT_EXPIRES_IN } | ||||||||||
| ); | ||||||||||
| res.json({ token }); | ||||||||||
| } catch (err) { | ||||||||||
| console.log("LOGIN ERROR CAUGHT:", err.message); | ||||||||||
| console.log("Is ZodError?", err instanceof z.ZodError); | ||||||||||
|
|
||||||||||
| if (err instanceof z.ZodError) { | ||||||||||
| return res.status(400).json({ | ||||||||||
| error: "Validation Failed", | ||||||||||
| details: err.issues | ||||||||||
| }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| console.error("Server Error:", err); | ||||||||||
| res.status(500).json({ error: "Internal Server Error" }); | ||||||||||
| } | ||||||||||
|
|
@@ -114,55 +154,95 @@ module.exports.deleteAccount = async (req, res) => { | |||||||||
| module.exports.sendOtp = async (req, res) => { | ||||||||||
| try { | ||||||||||
| const { email } = onlyEmailSchema.parse(req.body); | ||||||||||
| const otp = Math.floor(100000 + Math.random() * 900000); | ||||||||||
| // Secure: OTP logging removed | ||||||||||
|
|
||||||||||
| const existingUser = await Developer.findOne({ email }); | ||||||||||
| if (!existingUser) return res.status(400).json({ error: "User not found" }); | ||||||||||
|
|
||||||||||
| if (existingUser.isVerified) return res.status(400).json({ error: "User already verified" }); | ||||||||||
|
|
||||||||||
| const existingOtp = await otpSchema.findOne({ userId: existingUser._id }); | ||||||||||
| if (existingOtp) { | ||||||||||
| await existingOtp.deleteOne(); | ||||||||||
| } | ||||||||||
| const newOtp = new otpSchema( | ||||||||||
| { | ||||||||||
| userId: existingUser._id, | ||||||||||
| otp | ||||||||||
| }); | ||||||||||
| newOtp.save(); | ||||||||||
| await sendOtp(email, otp); | ||||||||||
| const otp = await createAndStoreOtp(existingUser._id); | ||||||||||
|
|
||||||||||
| await sendOtp(email, otp); // Send raw OTP to user's email | ||||||||||
| res.json({ message: "OTP sent successfully" }); | ||||||||||
| } catch (err) { | ||||||||||
| if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); | ||||||||||
| console.error(err); | ||||||||||
| res.status(500).json({ error: "Internal Server Error" }); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
|
|
||||||||||
| module.exports.verifyOtp = async (req, res) => { | ||||||||||
| try { | ||||||||||
| const { email } = onlyEmailSchema.parse(req.body); | ||||||||||
| const { otp } = req.body; | ||||||||||
| const existingUser = await Developer.findOne({ email }).sort({ createdAt: -1 }); | ||||||||||
| if (!existingUser) return res.status(400).json({ error: "User not found" }); | ||||||||||
| const { email, otp } = verifyOtpSchema.parse(req.body); | ||||||||||
|
|
||||||||||
| const existingOtp = await otpSchema.findOne({ userId: existingUser._id }); | ||||||||||
| if (!existingOtp) return res.status(400).json({ error: "You havn't requested an OTP" }); | ||||||||||
| const existingUser = await Developer.findOne({ email }); | ||||||||||
| if (!existingUser) return res.status(400).json({ error: "User not found" }); | ||||||||||
|
|
||||||||||
| // Secure: OTP verification logging removed | ||||||||||
| if (existingOtp.otp !== otp) return res.status(400).json({ error: "Incorrect OTP" }); | ||||||||||
| const otpDoc = await validateOtp(existingUser._id, otp); | ||||||||||
|
|
||||||||||
| await existingOtp.deleteOne(); | ||||||||||
| await otpDoc.deleteOne(); | ||||||||||
| existingUser.isVerified = true; | ||||||||||
| await existingUser.save(); | ||||||||||
|
|
||||||||||
| // Generate new token with isVerified: true | ||||||||||
| const token = jwt.sign({ _id: existingUser._id, isVerified: existingUser.isVerified }, process.env.JWT_SECRET); | ||||||||||
| // FIX 1: JWT with expiry | ||||||||||
| const token = jwt.sign( | ||||||||||
| { _id: existingUser._id, isVerified: true }, | ||||||||||
| process.env.JWT_SECRET, | ||||||||||
| { expiresIn: JWT_EXPIRES_IN } | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| res.status(200).json({ message: "OTP verified successfully", token }); | ||||||||||
| } catch (err) { | ||||||||||
| if (err.status) return res.status(err.status).json({ error: err.message }); | ||||||||||
| if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); | ||||||||||
| console.error(err); | ||||||||||
| res.status(500).json({ error: "Internal Server Error" }); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| // FIX 5: Forgot Password — generate + send reset OTP | ||||||||||
| module.exports.forgotPassword = async (req, res) => { | ||||||||||
| try { | ||||||||||
| const { email } = onlyEmailSchema.parse(req.body); | ||||||||||
|
|
||||||||||
| const dev = await Developer.findOne({ email }); | ||||||||||
| // Return same message regardless of whether email exists (prevents user enumeration) | ||||||||||
| if (!dev) return res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); | ||||||||||
|
|
||||||||||
| const otp = await createAndStoreOtp(dev._id); | ||||||||||
|
|
||||||||||
| await sendOtp(email, otp, { subject: "Password Reset OTP — urBackend" }); | ||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||
| res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); | ||||||||||
| } catch (err) { | ||||||||||
| if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); | ||||||||||
| console.error(err); | ||||||||||
| res.status(500).json({ error: "Internal Server Error" }); | ||||||||||
| } | ||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| // FIX 5: Reset Password — verify OTP then set new password | ||||||||||
| module.exports.resetPassword = async (req, res) => { | ||||||||||
| try { | ||||||||||
| const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); | ||||||||||
|
|
||||||||||
| const dev = await Developer.findOne({ email }); | ||||||||||
| if (!dev) return res.status(400).json({ error: "User not found" }); | ||||||||||
|
|
||||||||||
| const otpDoc = await validateOtp(dev._id, otp); | ||||||||||
|
|
||||||||||
| // OTP matched — update password | ||||||||||
| await otpDoc.deleteOne(); | ||||||||||
| const salt = await bcrypt.genSalt(10); | ||||||||||
| dev.password = await bcrypt.hash(newPassword, salt); | ||||||||||
| await dev.save(); | ||||||||||
|
|
||||||||||
| res.status(200).json({ message: "Password reset successfully. Please log in with your new password." }); | ||||||||||
| } catch (err) { | ||||||||||
| if (err.status) return res.status(err.status).json({ error: err.message }); | ||||||||||
| if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); | ||||||||||
| console.error(err); | ||||||||||
|
Comment on lines
+245
to
+246
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Zod v4, the canonical array of validation problems on a
The old v3 property:
is no longer reliable in v4 (many users hit it as “removed” during v3→v4 migration), and the expected replacement is to read Fix: replace If you need temporary compatibility (supporting both v3 + v4 in shared code), use: const issues = (err as any).issues ?? (err as any).errors ?? [];Sources: Zod v4 migration/changelog (notes error-handling changes and uses References: [1] [2] [3] 🏁 Script executed: # Find and examine the auth.controller.js file
find . -name "auth.controller.js" -type fRepository: yash-pouranik/urBackend Length of output: 107 🏁 Script executed: # Examine the auth.controller.js file, focusing on lines around 64 and 254-255
cat -n ./backend/controllers/auth.controller.js | sed -n '60,70p;250,260p'Repository: yash-pouranik/urBackend Length of output: 887 Change line 254 to use Line 254 uses Suggested fix- if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
+ if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| res.status(500).json({ error: "Internal Server Error" }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| const rateLimit = require('express-rate-limit'); | ||
|
|
||
| // Strict limiter for sensitive auth endpoints (login, register) | ||
| const authLimiter = rateLimit({ | ||
| windowMs: 15 * 60 * 1000, // 15 minutes | ||
| max: 10, | ||
| message: { error: "Too many attempts. Please try again in 15 minutes." }, | ||
| skip: (req) => process.env.NODE_ENV === 'development', | ||
| standardHeaders: true, | ||
| legacyHeaders: false, | ||
| }); | ||
|
Comment on lines
+4
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check for rate-limit store configurations and dependencies
echo "=== Searching for rate-limit store references ==="
rg -n "express-rate-limit|rate-limit-redis|RedisStore|store:" backend --max-count=20
echo -e "\n=== Checking package.json for rate-limit related dependencies ==="
cat package.json | grep -A 10 '"dependencies"' | grep -i "rate-limit\|redis"
echo -e "\n=== Looking for Redis configuration ==="
rg -n "redis|REDIS" --max-count=20
echo -e "\n=== Checking for multi-instance/deployment configs ==="
ls -la | grep -E "docker|compose|kubernetes|helm|deploy"
find . -maxdepth 2 -type f \( -name "docker-compose*" -o -name "Dockerfile" -o -name "*.yml" -o -name "*.yaml" \) 2>/dev/null | head -10Repository: yash-pouranik/urBackend Length of output: 5703 🏁 Script executed: #!/bin/bash
echo "=== auth_limiter.js (lines 1-20) ==="
head -20 backend/middleware/auth_limiter.js
echo -e "\n=== api_usage.js (lines 1-20) ==="
head -20 backend/middleware/api_usage.js
echo -e "\n=== app.js rateLimit usage (line 13 context) ==="
sed -n '10,30p' backend/app.js
echo -e "\n=== redis.js config ==="
cat backend/config/redis.jsRepository: yash-pouranik/urBackend Length of output: 2902 Integrate Redis store with rate-limiters for multi-instance deployments. The middleware currently uses the default in-memory store. While Redis is already configured in the project ( Pass the Redis client to Example fix for auth_limiter.jsconst rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('../config/redis');
const authLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:auth:'
}),
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: "Too many attempts. Please try again in 15 minutes." },
skip: (req) => process.env.NODE_ENV === 'development',
standardHeaders: true,
legacyHeaders: false,
});
module.exports = { authLimiter };Apply the same pattern to 🤖 Prompt for AI Agents |
||
|
|
||
| module.exports = { authLimiter }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| const mongoose = require('mongoose'); | ||
|
|
||
| const otpSchema = new mongoose.Schema({ | ||
| userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User' }, | ||
| otp: { type: String, required: true }, | ||
| userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'Developer' }, | ||
| otp: { type: String, required: true }, // bcrypt hash (FIX 2) | ||
| attempts: { type: Number, default: 0 }, // brute-force guard (FIX 4) | ||
|
Comment on lines
+4
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enforce one active OTP document per user. Line [4] is not unique. Concurrent OTP requests can create multiple records, weakening Line [6] attempt tracking and allowing stale OTP documents to persist. 🧱 Suggested schema hardening- userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'Developer' },
+ userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'Developer', unique: true, index: true },
otp: { type: String, required: true }, // bcrypt hash (FIX 2)
- attempts: { type: Number, default: 0 }, // brute-force guard (FIX 4)
+ attempts: { type: Number, default: 0, min: 0 }, // brute-force guard (FIX 4)🤖 Prompt for AI Agents |
||
| createdAt: { type: Date, default: Date.now, expires: '10m' } // MongoDB TTL index | ||
| }); | ||
|
|
||
| module.exports = mongoose.model('Otp', otpSchema); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,18 @@ module.exports.onlyEmailSchema = z.object({ | |
| email: z.string().email("Invalid email format") | ||
| }); | ||
|
|
||
| module.exports.verifyOtpSchema = z.object({ | ||
| email: z.string().email("Invalid email format"), | ||
| otp: z.string().length(6, "OTP must be 6 digits") | ||
| }); | ||
|
|
||
| module.exports.resetPasswordSchema = z.object({ | ||
| email: z.string().email("Invalid email format"), | ||
| otp: z.string().length(6, "OTP must be 6 digits"), | ||
| newPassword: z.string().min(6, "Password must be at least 6 characters").max(100, "Password is too long.") | ||
| }); | ||
|
Comment on lines
+46
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tighten OTP validation to match the generated format. Line [43] currently accepts any string with length ≥ 6. Since OTPs are generated as 6-digit codes, enforce exactly six digits to avoid wasting attempts on invalid formats. 🔧 Suggested change module.exports.resetPasswordSchema = z.object({
email: z.string().email("Invalid email format"),
- otp: z.string().min(6, "OTP is required"),
+ otp: z.string().regex(/^\d{6}$/, "OTP must be a 6-digit code"),
newPassword: z.string().min(6, "Password must be at least 6 characters").max(100, "Password is too long.")
});🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| module.exports.createProjectSchema = z.object({ | ||
| name: z.string().min(1, "Project name is required"), | ||
| description: z.string().optional() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.