Skip to content

Commit dd88c50

Browse files
committed
feat: add rate limiting for authentication endpoints
Implement comprehensive rate limiting to prevent brute force attacks and email enumeration on authentication endpoints. Backend changes: - Add express-rate-limit package (v8.2.1) - Create authRateLimiter: 5 requests/hour for magic links - Create strictAuthRateLimiter: 3 requests/15min for password resets - Apply IP-based rate limiting with IPv6 support - Return structured error responses with retry time Frontend changes: - Add rate limit error detection (429 status) - Display user-friendly error messages with Shield icon - Show calculated retry time in minutes - Disable submit buttons when rate limited - Add red security-themed error styling Security improvements: - Prevents brute force authentication attempts - Mitigates email enumeration attacks - IP-based tracking with proper IPv6 handling - Clear user feedback with retry information Tested with curl: both endpoints correctly limit requests and return proper error messages with retry times.
1 parent 848023e commit dd88c50

5 files changed

Lines changed: 173 additions & 16 deletions

File tree

package-lock.json

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"cors": "^2.8.5",
3131
"dotenv": "^16.3.0",
3232
"express": "^4.18.0",
33+
"express-rate-limit": "^8.2.1",
3334
"express-session": "^1.18.2",
3435
"graphql": "^16.8.0",
3536
"graphql-scalars": "^1.22.0",

packages/server/src/index.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { emailService } from './auth/email-service.js';
2929
import { exec } from 'child_process';
3030
import { promisify } from 'util';
3131
import fs from 'fs';
32+
import rateLimit from 'express-rate-limit';
3233

3334
const execAsync = promisify(exec);
3435

@@ -552,6 +553,43 @@ async function startServer() {
552553
}
553554
});
554555

556+
// Rate limiting configuration for authentication endpoints
557+
const authRateLimiter = rateLimit({
558+
windowMs: 60 * 60 * 1000, // 1 hour
559+
max: 5, // Max 5 requests per hour per IP
560+
standardHeaders: true,
561+
legacyHeaders: false,
562+
skipSuccessfulRequests: false,
563+
handler: (req, res) => {
564+
const retryAfter = Math.ceil((req.rateLimit.resetTime?.getTime() || Date.now()) / 1000 - Date.now() / 1000);
565+
console.log(`⚠️ Rate limit exceeded for IP: ${req.ip}`); // eslint-disable-line no-console
566+
res.status(429).json({
567+
error: 'Too many requests',
568+
message: `You've exceeded the maximum number of authentication requests. Please try again in ${Math.ceil(retryAfter / 60)} minutes.`,
569+
retryAfter,
570+
rateLimitExceeded: true
571+
});
572+
}
573+
});
574+
575+
const strictAuthRateLimiter = rateLimit({
576+
windowMs: 15 * 60 * 1000, // 15 minutes
577+
max: 3, // Max 3 requests per 15 minutes per IP
578+
standardHeaders: true,
579+
legacyHeaders: false,
580+
skipSuccessfulRequests: false,
581+
handler: (req, res) => {
582+
const retryAfter = Math.ceil((req.rateLimit.resetTime?.getTime() || Date.now()) / 1000 - Date.now() / 1000);
583+
console.log(`⚠️ Strict rate limit exceeded for IP: ${req.ip}`); // eslint-disable-line no-console
584+
res.status(429).json({
585+
error: 'Too many requests',
586+
message: `Too many authentication attempts. Please wait ${Math.ceil(retryAfter / 60)} minutes before trying again.`,
587+
retryAfter,
588+
rateLimitExceeded: true
589+
});
590+
}
591+
});
592+
555593
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
556594

557595
app.get(
@@ -615,23 +653,31 @@ async function startServer() {
615653

616654
app.options('/auth/magic-link/request', cors<cors.CorsRequest>(magicLinkCorsOptions));
617655

618-
app.post('/auth/magic-link/request', cors<cors.CorsRequest>(magicLinkCorsOptions), express.json(), async (req, res) => {
656+
app.post('/auth/magic-link/request', authRateLimiter, cors<cors.CorsRequest>(magicLinkCorsOptions), express.json(), async (req, res) => {
619657
try {
620658
const { email } = req.body;
621659

622660
if (!email || typeof email !== 'string') {
623661
return res.status(400).json({ error: 'Email is required' });
624662
}
625663

626-
const magicLink = await sqliteAuthStore.createMagicLink(email);
627-
await emailService.sendMagicLink(email, magicLink.token);
664+
const user = await sqliteAuthStore.findUserByEmailOrUsername(email);
628665

629-
console.log(`✉️ Magic link sent to: ${email}`); // eslint-disable-line no-console
666+
if (user) {
667+
const magicLink = await sqliteAuthStore.createMagicLink(email);
668+
await emailService.sendMagicLink(email, magicLink.token);
669+
console.log(`✉️ Magic link sent to: ${email}`); // eslint-disable-line no-console
670+
} else {
671+
console.log(`⚠️ Magic link requested for non-existent user: ${email}`); // eslint-disable-line no-console
672+
}
630673

631674
res.json({
632675
success: true,
633-
message: 'Magic link sent! Check your email.',
634-
expiresAt: magicLink.expiresAt
676+
userExists: !!user,
677+
message: user
678+
? 'Magic link sent! Check your email.'
679+
: 'This email is not registered in our system.',
680+
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString()
635681
});
636682
} catch (error) {
637683
console.error('❌ Magic link request failed:', error); // eslint-disable-line no-console
@@ -674,7 +720,7 @@ async function startServer() {
674720

675721
app.options('/auth/forgot-password', cors<cors.CorsRequest>(forgotPasswordCorsOptions));
676722

677-
app.post('/auth/forgot-password', cors<cors.CorsRequest>(forgotPasswordCorsOptions), express.json(), async (req, res) => {
723+
app.post('/auth/forgot-password', strictAuthRateLimiter, cors<cors.CorsRequest>(forgotPasswordCorsOptions), express.json(), async (req, res) => {
678724
try {
679725
const { email } = req.body;
680726

packages/web/src/pages/ForgotPassword.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from 'react';
22
import { Link } from 'react-router-dom';
3-
import { Mail, ArrowLeft, CheckCircle, XCircle } from 'lucide-react';
3+
import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react';
44
import { TlsStatusIndicator } from '../components/TlsStatusIndicator';
55
import { isValidEmail } from '../utils/validation';
66

@@ -10,6 +10,8 @@ export function ForgotPassword() {
1010
const [resetSent, setResetSent] = useState(false);
1111
const [error, setError] = useState('');
1212
const [emailValid, setEmailValid] = useState<boolean | null>(null);
13+
const [rateLimitError, setRateLimitError] = useState('');
14+
const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState<number | null>(null);
1315

1416
const handleSubmit = async (e: React.FormEvent) => {
1517
e.preventDefault();
@@ -26,6 +28,8 @@ export function ForgotPassword() {
2628

2729
setLoading(true);
2830
setError('');
31+
setRateLimitError('');
32+
setRateLimitRetryAfter(null);
2933

3034
try {
3135
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127';
@@ -40,7 +44,14 @@ export function ForgotPassword() {
4044
const data = await response.json();
4145

4246
if (response.ok) {
43-
setResetSent(true);
47+
if (data.userExists === false) {
48+
setError('This email is not registered in our system.');
49+
} else {
50+
setResetSent(true);
51+
}
52+
} else if (response.status === 429 && data.rateLimitExceeded) {
53+
setRateLimitError(data.message || 'Too many requests. Please try again later.');
54+
setRateLimitRetryAfter(data.retryAfter || null);
4455
} else {
4556
setError(data.error || 'Failed to send reset link');
4657
}
@@ -140,13 +151,48 @@ export function ForgotPassword() {
140151
</div>
141152
)}
142153
</div>
143-
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
154+
{error && (
155+
<div className="mt-3 p-4 bg-amber-900/20 border border-amber-500/30 rounded-xl">
156+
<p className="text-sm text-amber-300 font-medium mb-2">
157+
⚠️ Account Not Found
158+
</p>
159+
<p className="text-xs text-amber-200/80 mb-3">
160+
We couldn't find an account with <strong>{email}</strong>
161+
</p>
162+
<Link
163+
to="/signup"
164+
className="inline-flex items-center text-sm text-teal-400 hover:text-teal-300 font-medium transition-colors"
165+
>
166+
Create a new account →
167+
</Link>
168+
</div>
169+
)}
170+
{rateLimitError && (
171+
<div className="mt-3 p-4 bg-red-900/20 border border-red-500/30 rounded-xl">
172+
<div className="flex items-start space-x-2">
173+
<Shield className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
174+
<div className="flex-1">
175+
<p className="text-sm text-red-300 font-medium mb-2">
176+
🛡️ Rate Limit Exceeded
177+
</p>
178+
<p className="text-xs text-red-200/80 mb-2">
179+
{rateLimitError}
180+
</p>
181+
{rateLimitRetryAfter && (
182+
<p className="text-xs text-red-300/60">
183+
Please try again in {Math.ceil(rateLimitRetryAfter / 60)} minute{Math.ceil(rateLimitRetryAfter / 60) !== 1 ? 's' : ''}.
184+
</p>
185+
)}
186+
</div>
187+
</div>
188+
</div>
189+
)}
144190
</div>
145191

146192
{/* Submit Button */}
147193
<button
148194
type="submit"
149-
disabled={loading}
195+
disabled={loading || !!rateLimitError}
150196
className="w-full bg-gradient-to-r from-teal-600 to-blue-600 hover:from-teal-500 hover:to-blue-500 border border-teal-400/50 hover:border-teal-300 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-teal-500/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:translate-y-0 flex items-center justify-center space-x-2"
151197
>
152198
{loading ? (

packages/web/src/pages/Signin.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,17 @@ export function Signin() {
359359
const data = await response.json();
360360

361361
if (response.ok) {
362-
setMagicLinkSent(true);
363-
setResendCooldown(60);
362+
if (data.userExists === false) {
363+
setErrors({ magicLinkEmail: 'This email is not registered in our system.' });
364+
} else {
365+
setMagicLinkSent(true);
366+
setResendCooldown(60);
367+
}
368+
} else if (response.status === 429 && data.rateLimitExceeded) {
369+
setErrors({
370+
rateLimitError: data.message || 'Too many requests. Please try again later.',
371+
rateLimitRetryAfter: data.retryAfter
372+
});
364373
} else {
365374
setErrors({ submit: data.error || 'Failed to send magic link' });
366375
}
@@ -570,13 +579,50 @@ export function Signin() {
570579
</div>
571580
)}
572581
</div>
573-
{errors.magicLinkEmail && <p className="mt-1 text-xs text-red-400">{errors.magicLinkEmail}</p>}
582+
{errors.magicLinkEmail && (
583+
<div className="mt-3 p-4 bg-amber-900/20 border border-amber-500/30 rounded-xl">
584+
<p className="text-sm text-amber-300 font-medium mb-2">
585+
⚠️ Account Not Found
586+
</p>
587+
<p className="text-xs text-amber-200/80 mb-3">
588+
We couldn't find an account with <strong>{formData.magicLinkEmail}</strong>
589+
</p>
590+
<Link
591+
to="/signup"
592+
className="inline-flex items-center text-sm text-teal-400 hover:text-teal-300 font-medium transition-colors"
593+
>
594+
Create a new account →
595+
</Link>
596+
</div>
597+
)}
598+
599+
{/* Rate Limit Error */}
600+
{errors.rateLimitError && (
601+
<div className="mt-3 p-4 bg-red-900/20 border border-red-500/30 rounded-xl">
602+
<div className="flex items-start space-x-2">
603+
<Shield className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
604+
<div className="flex-1">
605+
<p className="text-sm text-red-300 font-medium mb-2">
606+
🛡️ Rate Limit Exceeded
607+
</p>
608+
<p className="text-xs text-red-200/80 mb-2">
609+
{errors.rateLimitError}
610+
</p>
611+
{errors.rateLimitRetryAfter && (
612+
<p className="text-xs text-red-300/60">
613+
Please try again in {Math.ceil(parseInt(errors.rateLimitRetryAfter) / 60)} minute{Math.ceil(parseInt(errors.rateLimitRetryAfter) / 60) !== 1 ? 's' : ''}.
614+
</p>
615+
)}
616+
</div>
617+
</div>
618+
</div>
619+
)}
574620
</div>
575621

576622
<button
577623
type="button"
578624
onClick={handleMagicLinkRequest}
579-
disabled={magicLinkLoading}
625+
disabled={magicLinkLoading || !!errors.rateLimitError}
580626
className="w-full bg-gradient-to-r from-teal-600 to-blue-600 hover:from-teal-500 hover:to-blue-500 border border-teal-400/50 hover:border-teal-300 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 hover:scale-[1.02] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-teal-500/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:translate-y-0 flex items-center justify-center space-x-2"
581627
>
582628
{magicLinkLoading ? (

0 commit comments

Comments
 (0)