Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ RATE_LIMIT_MAX_REQUESTS=100

# Logging
LOG_LEVEL=info

# Authentication (JWT)
# Generate a strong secret: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=your-jwt-secret-min-32-chars

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Placeholder value your-jwt-secret-min-32-chars is 28 characters, but its name suggests a minimum of 32. A user who copies this file literally might not notice the discrepancy. Either pad the placeholder to 32+ characters (e.g. your-jwt-secret-min-32-chars-here) or rename it to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/.env.example, line 30:

<comment>Placeholder value `your-jwt-secret-min-32-chars` is 28 characters, but its name suggests a minimum of 32. A user who copies this file literally might not notice the discrepancy. Either pad the placeholder to 32+ characters (e.g. `your-jwt-secret-min-32-chars-here`) or rename it to match.</comment>

<file context>
@@ -24,3 +24,8 @@ RATE_LIMIT_MAX_REQUESTS=100
+
+# Authentication (JWT)
+# Generate a strong secret: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
+JWT_SECRET=your-jwt-secret-min-32-chars
+JWT_EXPIRES_IN=7d
</file context>
Suggested change
JWT_SECRET=your-jwt-secret-min-32-chars
JWT_SECRET=your-jwt-secret-min-32-chars-here

JWT_EXPIRES_IN=7d
87 changes: 87 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@
"license": "MIT",
"dependencies": {
"@google/genai": "^1.0.0",
"bcryptjs": "^3.0.3",
"compression": "^1.7.5",
"pg": "^8.13.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"express-validator": "^7.2.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.0",
"pg": "^8.13.0",
"winston": "^3.14.0"
},
"devDependencies": {
Expand Down
9 changes: 8 additions & 1 deletion backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const validateEnvVars = (requiredVars) => {
};

// Validate critical environment variables
const requiredVars = ['DATABASE_URL'];
const requiredVars = ['DATABASE_URL', 'JWT_SECRET'];

// Only validate in production, allow fallbacks in development
if (process.env.NODE_ENV === 'production') {
Expand All @@ -64,6 +64,13 @@ const config = {
url: process.env.DATABASE_URL || '',
},

// Authentication (JWT)
auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
bcryptRounds: 12,
},

// Gemini AI configuration
gemini: {
apiKey: process.env.GEMINI_API_KEY || '',
Expand Down
84 changes: 84 additions & 0 deletions backend/src/controllers/authController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Auth Controller
* Handles HTTP request/response for authentication endpoints.
* Validates input, delegates to authService, and formats responses.
*/

import { asyncHandler, APIError } from '../middleware/errorHandler.js';
import * as authService from '../services/authService.js';

/**
* POST /api/v1/auth/register
* Create a new user account.
* Body: { email, password, name? }
*/
export const register = asyncHandler(async (req, res) => {
const { email, password, name } = req.body;

// Input validation
if (!email || !password) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed registration bodies return 500 instead of VALIDATION_ERROR: an object name reaches name?.trim() in registerUser, and non-string passwords reach bcrypt. Validate field types before delegating.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/controllers/authController.js, line 19:

<comment>Malformed registration bodies return 500 instead of `VALIDATION_ERROR`: an object `name` reaches `name?.trim()` in `registerUser`, and non-string passwords reach bcrypt. Validate field types before delegating.</comment>

<file context>
@@ -0,0 +1,84 @@
+  const { email, password, name } = req.body;
+
+  // Input validation
+  if (!email || !password) {
+    throw new APIError('Email and password are required', 400, 'VALIDATION_ERROR');
+  }
</file context>

throw new APIError('Email and password are required', 400, 'VALIDATION_ERROR');
}

// Basic email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new APIError('Invalid email format', 400, 'VALIDATION_ERROR');
}

if (password.length < 8) {
throw new APIError('Password must be at least 8 characters', 400, 'VALIDATION_ERROR');
}

if (password.length > 128) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Password length validation allows up to 128 characters, but bcrypt silently truncates at 72 bytes. A password between 73–128 characters passes validation, yet only the first 72 bytes are hashed and verified. Consider capping the password at 72 characters or adding a warning so users aren't misled about the effective strength of longer passwords.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/controllers/authController.js, line 33:

<comment>Password length validation allows up to 128 characters, but bcrypt silently truncates at 72 bytes. A password between 73–128 characters passes validation, yet only the first 72 bytes are hashed and verified. Consider capping the password at 72 characters or adding a warning so users aren't misled about the effective strength of longer passwords.</comment>

<file context>
@@ -0,0 +1,84 @@
+    throw new APIError('Password must be at least 8 characters', 400, 'VALIDATION_ERROR');
+  }
+
+  if (password.length > 128) {
+    throw new APIError('Password must be at most 128 characters', 400, 'VALIDATION_ERROR');
+  }
</file context>

throw new APIError('Password must be at most 128 characters', 400, 'VALIDATION_ERROR');
}

try {
const result = await authService.registerUser(email, password, name);
res.status(201).json({ success: true, data: result });
} catch (err) {
if (err.statusCode === 409) {
throw new APIError(err.message, 409, err.code || 'EMAIL_EXISTS');
}
throw err;
}
});

/**
* POST /api/v1/auth/login
* Authenticate with email and password.
* Body: { email, password }
*/
export const login = asyncHandler(async (req, res) => {
const { email, password } = req.body;

if (!email || !password) {
throw new APIError('Email and password are required', 400, 'VALIDATION_ERROR');
}

try {
const result = await authService.loginUser(email, password);
res.json({ success: true, data: result });
} catch (err) {
if (err.statusCode === 401) {
throw new APIError(err.message, 401, err.code || 'INVALID_CREDENTIALS');
}
throw err;
}
});

/**
* GET /api/v1/auth/me
* Get the current user's profile.
* Requires: Authorization: Bearer <token>
*/
export const me = asyncHandler(async (req, res) => {
const user = await authService.getUserById(req.user.id);

if (!user) {
throw new APIError('User not found', 404, 'NOT_FOUND');
}

res.json({ success: true, data: { user } });
});
59 changes: 59 additions & 0 deletions backend/src/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* JWT Authentication Middleware
* Extracts and verifies JWT from the Authorization header.
* Sets req.user = { id, email } on success.
*/

import jwt from 'jsonwebtoken';
import config from '../config/index.js';
import { APIError } from './errorHandler.js';

/**
* requireAuth — blocks unauthenticated requests with 401.
* Expects header: Authorization: Bearer <token>
*/
export const requireAuth = (req, _res, next) => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Guest-capable routes silently ignore valid JWTs when clients use a lowercase bearer scheme. Parse the authorization scheme case-insensitively here as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/middleware/auth.js, line 18:

<comment>Guest-capable routes silently ignore valid JWTs when clients use a lowercase `bearer` scheme. Parse the authorization scheme case-insensitively here as well.</comment>

<file context>
@@ -0,0 +1,59 @@
+export const requireAuth = (req, _res, next) => {
+  const authHeader = req.headers.authorization;
+
+  if (!authHeader || !authHeader.startsWith('Bearer ')) {
+    throw new APIError('Authentication required', 401, 'UNAUTHORIZED');
+  }
</file context>

throw new APIError('Authentication required', 401, 'UNAUTHORIZED');
}

const token = authHeader.split(' ')[1];

try {
const decoded = jwt.verify(token, config.auth.jwtSecret);
req.user = { id: decoded.sub, email: decoded.email };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new APIError('Token expired — please log in again', 401, 'TOKEN_EXPIRED');
}
throw new APIError('Invalid token', 401, 'INVALID_TOKEN');
}
};

/**
* optionalAuth — attaches user if a valid token is present, but does NOT block.
* Useful for routes that work for both guests and logged-in users.
* Sets req.user = null when no token or invalid token.
*/
export const optionalAuth = (req, _res, next) => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = null;
return next();
}

const token = authHeader.split(' ')[1];

try {
const decoded = jwt.verify(token, config.auth.jwtSecret);
req.user = { id: decoded.sub, email: decoded.email };
} catch {
req.user = null;
}

next();
};
3 changes: 2 additions & 1 deletion backend/src/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

export { APIError, notFoundHandler, errorHandler, asyncHandler } from './errorHandler.js';
export { validate, validationRules } from './validation.js';
export { generalLimiter, aiLimiter, ttsLimiter } from './rateLimiter.js';
export { generalLimiter, aiLimiter, ttsLimiter, authLimiter } from './rateLimiter.js';
export { requireAuth, optionalAuth } from './auth.js';
13 changes: 13 additions & 0 deletions backend/src/middleware/rateLimiter.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,21 @@ export const ttsLimiter = rateLimit({
handler: createLimitHandler('tts'),
});

/**
* Strict limiter for authentication endpoints
* Protects against brute-force attacks
*/
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Backend rate-limit documentation now omits the auth tier and incorrectly describes all tiers as one-minute windows. Update the README with the 20-per-15-minute authentication limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/middleware/rateLimiter.js, line 81:

<comment>Backend rate-limit documentation now omits the auth tier and incorrectly describes all tiers as one-minute windows. Update the README with the 20-per-15-minute authentication limit.</comment>

<file context>
@@ -73,8 +73,21 @@ export const ttsLimiter = rateLimit({
+ * Protects against brute-force attacks
+ */
+export const authLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 20, // Limit each IP to 20 requests per window
+  standardHeaders: true,
</file context>

max: 20, // Limit each IP to 20 requests per window
standardHeaders: true,
legacyHeaders: false,
handler: createLimitHandler('auth'),
});

export default {
generalLimiter,
aiLimiter,
ttsLimiter,
authLimiter,
};
27 changes: 27 additions & 0 deletions backend/src/routes/authRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Auth Routes
* Handles user registration, login, and profile endpoints.
*
* POST /register — Create a new account (open registration)
* POST /login — Authenticate and receive JWT
* GET /me — Get current user profile (requires auth)
*/

import { Router } from 'express';
import * as authController from '../controllers/authController.js';
import { requireAuth } from '../middleware/auth.js';
import { authLimiter } from '../middleware/rateLimiter.js';

const router = Router();

// Apply strict rate limiting to auth routes
router.use(authLimiter);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: GET /me is now rate-limited with the same strict brute-force policy as login/register, so normal session checks can start returning 429 after repeated profile fetches from one IP. Scoping authLimiter to /register and /login keeps brute-force protection without throttling authenticated profile reads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/src/routes/authRoutes.js, line 18:

<comment>`GET /me` is now rate-limited with the same strict brute-force policy as login/register, so normal session checks can start returning 429 after repeated profile fetches from one IP. Scoping `authLimiter` to `/register` and `/login` keeps brute-force protection without throttling authenticated profile reads.</comment>

<file context>
@@ -0,0 +1,27 @@
+const router = Router();
+
+// Apply strict rate limiting to auth routes
+router.use(authLimiter);
+
+// Public routes
</file context>


// Public routes
router.post('/register', authController.register);
router.post('/login', authController.login);

// Protected routes
router.get('/me', requireAuth, authController.me);

export default router;
Loading