Skip to content

Commit 3e94214

Browse files
committed
feat: add signup rate limiting and fix TypeScript errors
This commit implements IP-based rate limiting for the signup endpoint and resolves all pre-existing TypeScript compilation errors in the server package. **Rate Limiting for Signup:** - Implement in-memory rate limiting (5 attempts/hour per IP) - Add automatic cleanup of expired rate limit entries - Pass Express request object to GraphQL context for IP extraction - Add rate limit error handling in Signup UI with countdown timer - Display user-friendly error messages with retry time **TypeScript Error Fixes:** - Add explicit return statements to all route handler response calls - Create type declaration for express-rate-limit (Request.rateLimit property) - Create type declaration for passport-openidconnect module - Add explicit type annotations to GitHub OAuth strategy callback **Files Changed:** - server/src/resolvers/sqlite-auth.ts: Rate limiting logic for signup - server/src/index.ts: GraphQL context update, return statement fixes - server/src/types/express-rate-limit.d.ts: Type definitions (new) - server/src/types/passport-openidconnect.d.ts: Type definitions (new) - server/src/auth/oauth-strategies.ts: Type annotations for GitHub strategy - web/src/pages/Signup.tsx: Rate limit error UI
1 parent dd88c50 commit 3e94214

6 files changed

Lines changed: 140 additions & 17 deletions

File tree

packages/server/src/auth/oauth-strategies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function configureOAuthStrategies() {
8888
callbackURL: process.env.GITHUB_CALLBACK_URL || 'https://localhost:4128/auth/github/callback',
8989
scope: ['user:email'],
9090
},
91-
async (_accessToken, _refreshToken, profile, done) => {
91+
async (_accessToken: string, _refreshToken: string, profile: any, done: any) => {
9292
try {
9393
const email = profile.emails?.[0]?.value;
9494
if (!email) {

packages/server/src/index.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ async function startServer() {
384384
driver: isNeo4jAvailable ? driver : null,
385385
user,
386386
isNeo4jAvailable,
387+
req,
387388
};
388389
},
389390
})
@@ -671,7 +672,7 @@ async function startServer() {
671672
console.log(`⚠️ Magic link requested for non-existent user: ${email}`); // eslint-disable-line no-console
672673
}
673674

674-
res.json({
675+
return res.json({
675676
success: true,
676677
userExists: !!user,
677678
message: user
@@ -681,7 +682,7 @@ async function startServer() {
681682
});
682683
} catch (error) {
683684
console.error('❌ Magic link request failed:', error); // eslint-disable-line no-console
684-
res.status(500).json({ error: 'Failed to send magic link' });
685+
return res.status(500).json({ error: 'Failed to send magic link' });
685686
}
686687
});
687688

@@ -742,7 +743,7 @@ async function startServer() {
742743
}
743744

744745
// Return response with userExists flag for different UI messages
745-
res.json({
746+
return res.json({
746747
success: true,
747748
userExists: !!user,
748749
message: user
@@ -752,7 +753,7 @@ async function startServer() {
752753
});
753754
} catch (error) {
754755
console.error('❌ Password reset request failed:', error); // eslint-disable-line no-console
755-
res.status(500).json({ error: 'Failed to send reset link' });
756+
return res.status(500).json({ error: 'Failed to send reset link' });
756757
}
757758
});
758759

@@ -810,13 +811,13 @@ async function startServer() {
810811

811812
console.log(`🔐 Password updated successfully for: ${result.email}`); // eslint-disable-line no-console
812813

813-
res.json({
814+
return res.json({
814815
success: true,
815816
message: 'Password updated successfully'
816817
});
817818
} catch (error) {
818819
console.error('❌ Password update failed:', error); // eslint-disable-line no-console
819-
res.status(500).json({ error: 'Failed to update password' });
820+
return res.status(500).json({ error: 'Failed to update password' });
820821
}
821822
});
822823

@@ -848,15 +849,15 @@ async function startServer() {
848849

849850
console.log(`🔗 Shareable link created for graph ${graphId}`); // eslint-disable-line no-console
850851

851-
res.json({
852+
return res.json({
852853
success: true,
853854
shareUrl,
854855
token: shareableLink.token,
855856
accessLevel: accessLevel || 'VIEW'
856857
});
857858
} catch (error) {
858859
console.error('❌ Failed to create shareable link:', error); // eslint-disable-line no-console
859-
res.status(500).json({ error: 'Failed to create shareable link' });
860+
return res.status(500).json({ error: 'Failed to create shareable link' });
860861
}
861862
});
862863

@@ -869,15 +870,15 @@ async function startServer() {
869870
return res.status(404).json({ error: 'Link not found or expired' });
870871
}
871872

872-
res.json({
873+
return res.json({
873874
valid: true,
874875
graphId: result.graphId,
875876
accessLevel: result.accessLevel,
876877
requiresSignIn: result.requiresSignIn
877878
});
878879
} catch (error) {
879880
console.error('❌ Failed to verify shareable link:', error); // eslint-disable-line no-console
880-
res.status(500).json({ error: 'Failed to verify link' });
881+
return res.status(500).json({ error: 'Failed to verify link' });
881882
}
882883
});
883884

packages/server/src/resolvers/sqlite-auth.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,44 @@ interface UpdateProfileInput {
2121
metadata?: string;
2222
}
2323

24+
interface RateLimitEntry {
25+
count: number;
26+
resetTime: number;
27+
}
28+
29+
const signupRateLimits = new Map<string, RateLimitEntry>();
30+
31+
function checkSignupRateLimit(ip: string): { allowed: boolean; retryAfter?: number } {
32+
const now = Date.now();
33+
const windowMs = 60 * 60 * 1000;
34+
const maxAttempts = 5;
35+
36+
const entry = signupRateLimits.get(ip);
37+
38+
if (!entry || now > entry.resetTime) {
39+
signupRateLimits.set(ip, { count: 1, resetTime: now + windowMs });
40+
return { allowed: true };
41+
}
42+
43+
if (entry.count >= maxAttempts) {
44+
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
45+
return { allowed: false, retryAfter };
46+
}
47+
48+
entry.count++;
49+
return { allowed: true };
50+
}
51+
52+
setInterval(() => {
53+
const now = Date.now();
54+
const entries = Array.from(signupRateLimits.entries());
55+
for (const [ip, entry] of entries) {
56+
if (now > entry.resetTime) {
57+
signupRateLimits.delete(ip);
58+
}
59+
}
60+
}, 5 * 60 * 1000);
61+
2462
// SQLite-only auth resolvers that don't depend on Neo4j
2563
export const sqliteAuthResolvers = {
2664
Query: {
@@ -313,12 +351,25 @@ export const sqliteAuthResolvers = {
313351
},
314352

315353
// Signup mutation
316-
signup: async (_: any, { input }: { input: SignupInput }) => {
354+
signup: async (_: any, { input }: { input: SignupInput }, context: any) => {
317355
try {
356+
const clientIp = context.req?.ip || context.req?.connection?.remoteAddress || 'unknown';
357+
358+
const rateLimit = checkSignupRateLimit(clientIp);
359+
if (!rateLimit.allowed) {
360+
throw new GraphQLError('Too many signup attempts. Please try again later.', {
361+
extensions: {
362+
code: 'RATE_LIMIT_EXCEEDED',
363+
retryAfter: rateLimit.retryAfter,
364+
rateLimitExceeded: true
365+
}
366+
});
367+
}
368+
318369
// Check if user already exists
319-
const existingUser = await sqliteAuthStore.findUserByEmailOrUsername(input.email) ||
370+
const existingUser = await sqliteAuthStore.findUserByEmailOrUsername(input.email) ||
320371
await sqliteAuthStore.findUserByEmailOrUsername(input.username);
321-
372+
322373
if (existingUser) {
323374
throw new GraphQLError('User already exists with that email or username', {
324375
extensions: { code: 'BAD_USER_INPUT' }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'express-rate-limit';
2+
3+
declare module 'express' {
4+
export interface Request {
5+
rateLimit: {
6+
limit: number;
7+
current: number;
8+
remaining: number;
9+
resetTime: Date | undefined;
10+
};
11+
}
12+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
declare module 'passport-openidconnect' {
2+
import { Strategy as PassportStrategy } from 'passport-strategy';
3+
import { Request } from 'express';
4+
5+
export interface StrategyOptions {
6+
issuer: string;
7+
authorizationURL: string;
8+
tokenURL: string;
9+
userInfoURL: string;
10+
clientID: string;
11+
clientSecret: string;
12+
callbackURL: string;
13+
scope?: string[];
14+
}
15+
16+
export type VerifyCallback = (error: Error | null, user?: any, info?: any) => void;
17+
18+
export type VerifyFunction = (
19+
issuer: string,
20+
profile: any,
21+
done: VerifyCallback
22+
) => void;
23+
24+
export class Strategy extends PassportStrategy {
25+
constructor(options: StrategyOptions, verify: VerifyFunction);
26+
name: string;
27+
authenticate(req: Request, options?: any): void;
28+
}
29+
}

packages/web/src/pages/Signup.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect } from 'react';
22
import { Link, useNavigate } from 'react-router-dom';
33
import { useMutation, gql } from '@apollo/client';
4-
import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info } from 'lucide-react';
4+
import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react';
55
import { TlsStatusIndicator } from '../components/TlsStatusIndicator';
66
import { PasswordRequirements } from '../components/PasswordRequirements';
77
import { isValidEmail, getPasswordStrength } from '../utils/validation';
@@ -62,13 +62,21 @@ export function Signup() {
6262
const [resendLoading, setResendLoading] = useState(false);
6363
const [resendMessage, setResendMessage] = useState('');
6464
const [resendCooldown, setResendCooldown] = useState(0);
65+
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
66+
const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState<number | null>(null);
6567

6668
const [signup, { loading }] = useMutation(SIGNUP_MUTATION, {
6769
onCompleted: (data) => {
6870
setSignupComplete(true);
6971
},
7072
onError: (error) => {
71-
setErrors({ submit: error.message });
73+
if (error.graphQLErrors?.[0]?.extensions?.rateLimitExceeded) {
74+
const retryAfter = error.graphQLErrors[0].extensions.retryAfter as number;
75+
setRateLimitError(error.message);
76+
setRateLimitRetryAfter(retryAfter);
77+
} else {
78+
setErrors({ submit: error.message });
79+
}
7280
}
7381
});
7482

@@ -588,8 +596,30 @@ export function Signup() {
588596
)}
589597
</div>
590598

599+
{/* Rate Limit Error */}
600+
{rateLimitError && (
601+
<div className="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+
{rateLimitError}
610+
</p>
611+
{rateLimitRetryAfter && (
612+
<p className="text-xs text-red-300/60">
613+
Please try again in {Math.ceil(rateLimitRetryAfter / 60)} minute(s).
614+
</p>
615+
)}
616+
</div>
617+
</div>
618+
</div>
619+
)}
620+
591621
{/* Submit Error */}
592-
{errors.submit && (
622+
{errors.submit && !rateLimitError && (
593623
<div className="p-3 bg-red-900 border border-red-700 rounded-lg">
594624
<p className="text-sm text-red-300">{errors.submit}</p>
595625
</div>

0 commit comments

Comments
 (0)