Skip to content

Commit 51dcf0a

Browse files
Merge pull request #33 from yash-pouranik/feat/auth-hardening
feat: harden developer auth (JWT expiry, OTP hashing, brute-force pro…
2 parents cc32bb6 + f117d4e commit 51dcf0a

9 files changed

Lines changed: 163 additions & 47 deletions

File tree

backend/Dockerfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
# ── Stage: Production ─────────────────────────────────────────────────────────
1+
# Stage: Production
22
FROM node:20-alpine
33

44
# Set working directory
55
WORKDIR /app
66

77
# Install dependencies first (layer caching)
8-
COPY package*.json ./
8+
COPY --chown=node:node package*.json ./
99
RUN npm ci --omit=dev
1010

1111
# Copy source
12-
COPY . .
12+
COPY --chown=node:node . .
13+
14+
# Switch to non-root user (security best practice)
15+
USER node
1316

1417
# Expose backend port
1518
EXPOSE 1234

backend/app.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const dashboardLimiter = rateLimit({
2828
skip: (req) => process.env.NODE_ENV === 'development',
2929
});
3030

31+
// FIX 3: Strict limiter for auth endpoints (login / register)
32+
const { authLimiter } = require('./middleware/auth_limiter');
33+
3134

3235
const adminWhitelist = ['https://urbackend.bitbros.in'];
3336

@@ -74,7 +77,9 @@ const storageRoute = require('./routes/storage');
7477
const schemaRoute = require('./routes/schemas');
7578

7679
// ROUTES SETUP
77-
app.use('/api/auth', dashboardLimiter, authRoute); // Developer Auth
80+
app.use('/api/auth/login', authLimiter); // Strict limiter on login
81+
app.use('/api/auth/register', authLimiter); // Strict limiter on register
82+
app.use('/api/auth', dashboardLimiter, authRoute); // Developer Auth (general)
7883
app.use('/api/projects', dashboardLimiter, projectRoute); // Project Mgmt
7984
app.use('/api/userAuth', limiter, logger, userAuthRoute);
8085
app.use('/api/data', limiter, cors(adminCorsOptions), logger, dataRoute);

backend/controllers/auth.controller.js

Lines changed: 111 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,53 @@ const bcrypt = require("bcryptjs");
55
const z = require("zod");
66
const jwt = require("jsonwebtoken");
77
const sendOtp = require("../utils/emailService");
8+
const crypto = require("crypto");
89
const {
910
loginSchema,
1011
changePasswordSchema,
1112
deleteAccountSchema,
12-
onlyEmailSchema
13+
onlyEmailSchema,
14+
resetPasswordSchema,
15+
verifyOtpSchema
1316
} = require("../utils/input.validation");
1417

18+
const JWT_EXPIRES_IN = '7d';
19+
const OTP_MAX_ATTEMPTS = 5;
1520

21+
async function createAndStoreOtp(userId) {
22+
const otp = crypto.randomInt(100000, 1000000).toString();
23+
24+
// Delete any existing OTP for this user
25+
await otpSchema.deleteOne({ userId });
26+
27+
// Hash OTP before storing
28+
const salt = await bcrypt.genSalt(10);
29+
const hashedOtp = await bcrypt.hash(otp, salt);
30+
31+
await new otpSchema({ userId, otp: hashedOtp }).save();
32+
return otp;
33+
}
34+
35+
// HELPER: Validate OTP with attempt tracking
36+
async function validateOtp(userId, passedOtp) {
37+
const otpDoc = await otpSchema.findOne({ userId });
38+
if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." };
39+
40+
if (otpDoc.attempts >= OTP_MAX_ATTEMPTS) {
41+
await otpDoc.deleteOne();
42+
throw { status: 429, message: "Too many incorrect attempts. Please request a new OTP." };
43+
}
44+
45+
const isMatch = await bcrypt.compare(passedOtp.toString(), otpDoc.otp);
46+
if (!isMatch) {
47+
otpDoc.attempts += 1;
48+
await otpDoc.save();
49+
const remaining = OTP_MAX_ATTEMPTS - otpDoc.attempts;
50+
throw { status: 400, message: `Incorrect OTP. ${remaining} attempt(s) remaining.` };
51+
}
52+
53+
return otpDoc;
54+
}
1655

1756
module.exports.register = async (req, res) => {
1857
try {
@@ -47,19 +86,20 @@ module.exports.login = async (req, res) => {
4786
const validPass = await bcrypt.compare(password, dev.password);
4887
if (!validPass) return res.status(400).json({ error: "Invalid password" });
4988

50-
const token = jwt.sign({ _id: dev._id, isVerified: dev.isVerified }, process.env.JWT_SECRET);
89+
// FIX 1: JWT now expires in 7 days
90+
const token = jwt.sign(
91+
{ _id: dev._id, isVerified: dev.isVerified },
92+
process.env.JWT_SECRET,
93+
{ expiresIn: JWT_EXPIRES_IN }
94+
);
5195
res.json({ token });
5296
} catch (err) {
53-
console.log("LOGIN ERROR CAUGHT:", err.message);
54-
console.log("Is ZodError?", err instanceof z.ZodError);
55-
5697
if (err instanceof z.ZodError) {
5798
return res.status(400).json({
5899
error: "Validation Failed",
59100
details: err.issues
60101
});
61102
}
62-
63103
console.error("Server Error:", err);
64104
res.status(500).json({ error: "Internal Server Error" });
65105
}
@@ -114,55 +154,95 @@ module.exports.deleteAccount = async (req, res) => {
114154
module.exports.sendOtp = async (req, res) => {
115155
try {
116156
const { email } = onlyEmailSchema.parse(req.body);
117-
const otp = Math.floor(100000 + Math.random() * 900000);
118-
// Secure: OTP logging removed
157+
119158
const existingUser = await Developer.findOne({ email });
120159
if (!existingUser) return res.status(400).json({ error: "User not found" });
121160

122161
if (existingUser.isVerified) return res.status(400).json({ error: "User already verified" });
123162

124-
const existingOtp = await otpSchema.findOne({ userId: existingUser._id });
125-
if (existingOtp) {
126-
await existingOtp.deleteOne();
127-
}
128-
const newOtp = new otpSchema(
129-
{
130-
userId: existingUser._id,
131-
otp
132-
});
133-
newOtp.save();
134-
await sendOtp(email, otp);
163+
const otp = await createAndStoreOtp(existingUser._id);
164+
165+
await sendOtp(email, otp); // Send raw OTP to user's email
135166
res.json({ message: "OTP sent successfully" });
136167
} catch (err) {
168+
if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
137169
console.error(err);
138170
res.status(500).json({ error: "Internal Server Error" });
139171
}
140172
}
141173

142174

143-
144175
module.exports.verifyOtp = async (req, res) => {
145176
try {
146-
const { email } = onlyEmailSchema.parse(req.body);
147-
const { otp } = req.body;
148-
const existingUser = await Developer.findOne({ email }).sort({ createdAt: -1 });
149-
if (!existingUser) return res.status(400).json({ error: "User not found" });
177+
const { email, otp } = verifyOtpSchema.parse(req.body);
150178

151-
const existingOtp = await otpSchema.findOne({ userId: existingUser._id });
152-
if (!existingOtp) return res.status(400).json({ error: "You havn't requested an OTP" });
179+
const existingUser = await Developer.findOne({ email });
180+
if (!existingUser) return res.status(400).json({ error: "User not found" });
153181

154-
// Secure: OTP verification logging removed
155-
if (existingOtp.otp !== otp) return res.status(400).json({ error: "Incorrect OTP" });
182+
const otpDoc = await validateOtp(existingUser._id, otp);
156183

157-
await existingOtp.deleteOne();
184+
await otpDoc.deleteOne();
158185
existingUser.isVerified = true;
159186
await existingUser.save();
160187

161-
// Generate new token with isVerified: true
162-
const token = jwt.sign({ _id: existingUser._id, isVerified: existingUser.isVerified }, process.env.JWT_SECRET);
188+
// FIX 1: JWT with expiry
189+
const token = jwt.sign(
190+
{ _id: existingUser._id, isVerified: true },
191+
process.env.JWT_SECRET,
192+
{ expiresIn: JWT_EXPIRES_IN }
193+
);
163194

164195
res.status(200).json({ message: "OTP verified successfully", token });
165196
} catch (err) {
197+
if (err.status) return res.status(err.status).json({ error: err.message });
198+
if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
199+
console.error(err);
200+
res.status(500).json({ error: "Internal Server Error" });
201+
}
202+
}
203+
204+
205+
// FIX 5: Forgot Password — generate + send reset OTP
206+
module.exports.forgotPassword = async (req, res) => {
207+
try {
208+
const { email } = onlyEmailSchema.parse(req.body);
209+
210+
const dev = await Developer.findOne({ email });
211+
// Return same message regardless of whether email exists (prevents user enumeration)
212+
if (!dev) return res.status(200).json({ message: "If this email is registered, an OTP has been sent." });
213+
214+
const otp = await createAndStoreOtp(dev._id);
215+
216+
await sendOtp(email, otp, { subject: "Password Reset OTP — urBackend" });
217+
res.status(200).json({ message: "If this email is registered, an OTP has been sent." });
218+
} catch (err) {
219+
if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
220+
console.error(err);
221+
res.status(500).json({ error: "Internal Server Error" });
222+
}
223+
}
224+
225+
226+
// FIX 5: Reset Password — verify OTP then set new password
227+
module.exports.resetPassword = async (req, res) => {
228+
try {
229+
const { email, otp, newPassword } = resetPasswordSchema.parse(req.body);
230+
231+
const dev = await Developer.findOne({ email });
232+
if (!dev) return res.status(400).json({ error: "User not found" });
233+
234+
const otpDoc = await validateOtp(dev._id, otp);
235+
236+
// OTP matched — update password
237+
await otpDoc.deleteOne();
238+
const salt = await bcrypt.genSalt(10);
239+
dev.password = await bcrypt.hash(newPassword, salt);
240+
await dev.save();
241+
242+
res.status(200).json({ message: "Password reset successfully. Please log in with your new password." });
243+
} catch (err) {
244+
if (err.status) return res.status(err.status).json({ error: err.message });
245+
if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
166246
console.error(err);
167247
res.status(500).json({ error: "Internal Server Error" });
168248
}

backend/middleware/auth_limiter.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const rateLimit = require('express-rate-limit');
2+
3+
// Strict limiter for sensitive auth endpoints (login, register)
4+
const authLimiter = rateLimit({
5+
windowMs: 15 * 60 * 1000, // 15 minutes
6+
max: 10,
7+
message: { error: "Too many attempts. Please try again in 15 minutes." },
8+
skip: (req) => process.env.NODE_ENV === 'development',
9+
standardHeaders: true,
10+
legacyHeaders: false,
11+
});
12+
13+
module.exports = { authLimiter };

backend/models/otp.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const mongoose = require('mongoose');
22

33
const otpSchema = new mongoose.Schema({
4-
userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'User' },
5-
otp: { type: String, required: true },
4+
userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: 'Developer' },
5+
otp: { type: String, required: true }, // bcrypt hash (FIX 2)
6+
attempts: { type: Number, default: 0 }, // brute-force guard (FIX 4)
67
createdAt: { type: Date, default: Date.now, expires: '10m' } // MongoDB TTL index
78
});
89

910
module.exports = mongoose.model('Otp', otpSchema);
11+

backend/routes/auth.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const {
77
changePassword,
88
deleteAccount,
99
sendOtp,
10-
verifyOtp
10+
verifyOtp,
11+
forgotPassword,
12+
resetPassword
1113
} = require('../controllers/auth.controller');
1214

1315

@@ -23,9 +25,12 @@ router.put('/change-password', authorization, changePassword);
2325
// DELETE ACCOUNT (Protected - Danger Zone)
2426
router.delete('/delete-account', authorization, deleteAccount);
2527

28+
// OTP (email verification)
2629
router.post('/send-otp', sendOtp);
27-
28-
2930
router.post('/verify-otp', verifyOtp);
3031

31-
module.exports = router;
32+
// PASSWORD RESET (FIX 5)
33+
router.post('/forgot-password', forgotPassword);
34+
router.post('/reset-password', resetPassword);
35+
36+
module.exports = router;

backend/utils/emailService.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ const dotenv = require('dotenv');
55
dotenv.config();
66
const resend = new Resend(process.env.RESEND_API_KEY || 're_dummy_key_for_testing');
77

8-
async function sendOtp(email, otp) {
8+
async function sendOtp(email, otp, { subject = "Verify your urBackend account" } = {}) {
99
try {
1010
const { data, error } = await resend.emails.send({
1111
from: 'urBackend <urbackend@bitbros.in>',
1212
to: email,
13-
subject: 'Verify your urBackend account',
13+
subject: subject,
1414
html: `
1515
<div style="font-family: Arial, sans-serif; background:#f4f6f8; padding:40px 0;">
1616
<div style="max-width:500px; margin:auto; background:white; border-radius:10px; padding:30px; text-align:center; box-shadow:0 4px 12px rgba(0,0,0,0.08);">

backend/utils/input.validation.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ module.exports.onlyEmailSchema = z.object({
3838
email: z.string().email("Invalid email format")
3939
});
4040

41+
module.exports.verifyOtpSchema = z.object({
42+
email: z.string().email("Invalid email format"),
43+
otp: z.string().length(6, "OTP must be 6 digits")
44+
});
45+
46+
module.exports.resetPasswordSchema = z.object({
47+
email: z.string().email("Invalid email format"),
48+
otp: z.string().length(6, "OTP must be 6 digits"),
49+
newPassword: z.string().min(6, "Password must be at least 6 characters").max(100, "Password is too long.")
50+
});
51+
52+
4153
module.exports.createProjectSchema = z.object({
4254
name: z.string().min(1, "Project name is required"),
4355
description: z.string().optional()

docker-compose.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ services:
55
image: mongo:7
66
container_name: urbackend_mongo
77
restart: unless-stopped
8-
ports:
9-
- '27017:27017'
108
volumes:
119
- mongo_data:/data/db
1210

@@ -15,8 +13,6 @@ services:
1513
image: redis:latest
1614
container_name: urbackend_redis
1715
restart: unless-stopped
18-
ports:
19-
- '6379:6379'
2016
volumes:
2117
- redis_data:/data
2218

0 commit comments

Comments
 (0)