Hamdy005 commited on
Commit
64f7965
·
1 Parent(s): 5afbc36

feat: implement in-memory email rate limiting and adjust RAG search parameters

Browse files
Files changed (6) hide show
  1. auth/constants.py +7 -0
  2. auth/rate_limiter.py +164 -0
  3. auth/routes.py +33 -1
  4. auth/schemas.py +6 -0
  5. main.py +1 -1
  6. rag/constants.py +6 -6
auth/constants.py CHANGED
@@ -13,3 +13,10 @@ MAX_FILE_SIZE_BYTES = 6 * 1024 * 1024 # 6 MB
13
  AVATAR_BUCKET = "avatars"
14
 
15
  PLACEHOLDER_DOMAINS = ["@placeholder.ai", "@studymate.ai"]
 
 
 
 
 
 
 
 
13
  AVATAR_BUCKET = "avatars"
14
 
15
  PLACEHOLDER_DOMAINS = ["@placeholder.ai", "@studymate.ai"]
16
+
17
+ # Rate limits per email action (3 emails per hour)
18
+ EMAIL_RATE_LIMITS = {
19
+ "email_verification": {"limit": 3, "window_seconds": 3600},
20
+ "forgot_password": {"limit": 3, "window_seconds": 3600},
21
+ "change_password_confirmation": {"limit": 3, "window_seconds": 3600},
22
+ }
auth/rate_limiter.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from collections import defaultdict
3
+ from typing import Dict, List, Tuple
4
+ from threading import Timer, Lock
5
+ from fastapi import HTTPException
6
+
7
+ # In-Memory Email Rate Limits per action (3 emails per 1 hour window, 60s per-send cooldown)
8
+ EMAIL_ACTION_LIMITS = {
9
+ "email_verification": {"limit": 3, "window_seconds": 3600, "cooldown_seconds": 60},
10
+ "forgot_password": {"limit": 3, "window_seconds": 3600, "cooldown_seconds": 60},
11
+ "change_password_confirmation": {"limit": 3, "window_seconds": 3600, "cooldown_seconds": 60},
12
+ }
13
+
14
+ # In-memory stores & thread lock
15
+ _store: Dict[str, Dict[str, List[float]]] = defaultdict(lambda: defaultdict(list))
16
+ _cooldowns: Dict[str, Dict[str, float]] = defaultdict(dict)
17
+ _lock = Lock()
18
+
19
+
20
+ def _cleanup_store():
21
+ """Periodically purges expired entries from _store and _cooldowns to prevent memory leaks."""
22
+ now = time.time()
23
+ with _lock:
24
+ for action, users in list(_store.items()):
25
+ config = EMAIL_ACTION_LIMITS.get(action, {})
26
+ window = config.get("window_seconds", 3600)
27
+ cutoff = now - window
28
+ for identifier, timestamps in list(users.items()):
29
+ filtered = [ts for ts in timestamps if ts > cutoff]
30
+ if filtered:
31
+ _store[action][identifier] = filtered
32
+ else:
33
+ del _store[action][identifier]
34
+
35
+ for action, users in list(_cooldowns.items()):
36
+ for identifier, until in list(users.items()):
37
+ if now >= until:
38
+ del _cooldowns[action][identifier]
39
+
40
+ # Schedule next cleanup run in 30 minutes
41
+ t = Timer(1800, _cleanup_store)
42
+ t.daemon = True
43
+ t.start()
44
+
45
+
46
+ # Start background cleanup timer (daemon thread so it doesn't block process exit)
47
+ _cleanup_timer = Timer(1800, _cleanup_store)
48
+ _cleanup_timer.daemon = True
49
+ _cleanup_timer.start()
50
+
51
+
52
+ def check_and_record_email_rate_limit(
53
+ action: str, identifier: str, cooldown_seconds: int = 60
54
+ ) -> Tuple[bool, int, int]:
55
+ """
56
+ Check and record an in-memory rate limit and per-send cooldown for a specific email action.
57
+
58
+ :param action: 'email_verification', 'forgot_password', or 'change_password_confirmation'
59
+ :param identifier: Email address or user ID
60
+ :param cooldown_seconds: Minimum seconds between individual sends (default 60s)
61
+ :return: (is_allowed, remaining_attempts, retry_after_seconds)
62
+ """
63
+ if action not in EMAIL_ACTION_LIMITS:
64
+ raise ValueError(f"Unknown action '{action}'. Allowed actions: {list(EMAIL_ACTION_LIMITS.keys())}")
65
+
66
+ config = EMAIL_ACTION_LIMITS[action]
67
+ limit = config["limit"]
68
+ window = config["window_seconds"]
69
+ cooldown = config.get("cooldown_seconds", cooldown_seconds)
70
+
71
+ now = time.time()
72
+ clean_id = identifier.lower().strip()
73
+
74
+ with _lock:
75
+ # 1. Check per-send cooldown first
76
+ cooldown_until = _cooldowns[action].get(clean_id, 0)
77
+ if now < cooldown_until:
78
+ wait = int(cooldown_until - now) + 1
79
+ cutoff = now - window
80
+ timestamps = [ts for ts in _store[action][clean_id] if ts > cutoff]
81
+ remaining = max(0, limit - len(timestamps))
82
+ return False, remaining, max(1, wait)
83
+
84
+ # 2. Check 1-hour window limit
85
+ cutoff = now - window
86
+ timestamps = [ts for ts in _store[action][clean_id] if ts > cutoff]
87
+
88
+ if len(timestamps) >= limit:
89
+ oldest = timestamps[0]
90
+ retry_after = int(oldest + window - now) + 1
91
+ _store[action][clean_id] = timestamps
92
+ return False, 0, max(1, retry_after)
93
+
94
+ # Allowed: record timestamp and set next cooldown
95
+ timestamps.append(now)
96
+ _store[action][clean_id] = timestamps
97
+ _cooldowns[action][clean_id] = now + cooldown
98
+
99
+ remaining = limit - len(timestamps)
100
+ return True, remaining, 0
101
+
102
+
103
+ def enforce_email_rate_limit(action: str, identifier: str) -> int:
104
+ """
105
+ Enforces in-memory rate limit and per-send cooldown. Raises HTTP 429 if violated.
106
+
107
+ :return: Number of remaining attempts in the current window.
108
+ """
109
+ allowed, remaining, retry_after = check_and_record_email_rate_limit(action, identifier)
110
+ if not allowed:
111
+ action_name = action.replace("_", " ").title()
112
+ if retry_after <= 60:
113
+ msg = f"Please wait {retry_after} seconds before requesting another {action_name} email."
114
+ else:
115
+ minutes = (retry_after + 59) // 60
116
+ msg = f"Rate limit exceeded for {action_name}. Maximum 3 emails per hour allowed. Please try again in {minutes} minute(s)."
117
+
118
+ raise HTTPException(
119
+ status_code=429,
120
+ detail=msg,
121
+ headers={"Retry-After": str(retry_after)}
122
+ )
123
+ return remaining
124
+
125
+
126
+ def get_email_rate_limit_status(action: str, identifier: str) -> Dict[str, int]:
127
+ """
128
+ Get current usage and cooldown status without recording a new attempt.
129
+ """
130
+ if action not in EMAIL_ACTION_LIMITS:
131
+ raise ValueError(f"Unknown action '{action}'. Allowed actions: {list(EMAIL_ACTION_LIMITS.keys())}")
132
+
133
+ config = EMAIL_ACTION_LIMITS[action]
134
+ limit = config["limit"]
135
+ window = config["window_seconds"]
136
+ cooldown = config.get("cooldown_seconds", 60)
137
+
138
+ now = time.time()
139
+ cutoff = now - window
140
+ clean_id = identifier.lower().strip()
141
+
142
+ with _lock:
143
+ timestamps = [ts for ts in _store[action][clean_id] if ts > cutoff]
144
+ used = len(timestamps)
145
+ remaining = max(0, limit - used)
146
+
147
+ cooldown_until = _cooldowns[action].get(clean_id, 0)
148
+ cooldown_remaining = max(0, int(cooldown_until - now) + 1) if now < cooldown_until else 0
149
+
150
+ window_retry_after = 0
151
+ if used >= limit and timestamps:
152
+ window_retry_after = max(1, int(timestamps[0] + window - now) + 1)
153
+
154
+ retry_after = max(cooldown_remaining, window_retry_after)
155
+
156
+ return {
157
+ "limit": limit,
158
+ "used": used,
159
+ "remaining": remaining,
160
+ "window_seconds": window,
161
+ "cooldown_seconds": cooldown,
162
+ "cooldown_remaining_seconds": cooldown_remaining,
163
+ "retry_after_seconds": retry_after
164
+ }
auth/routes.py CHANGED
@@ -9,12 +9,18 @@ from src.config import settings
9
  from src.database import get_auth_supabase, get_supabase
10
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
11
  from src.dependencies import get_current_user_id, get_current_user
12
- from .schemas import ProfileUpdateRequest
13
  from .constants import (
14
  ALLOWED_MIME_TYPES,
15
  MAX_FILE_SIZE_BYTES,
16
  AVATAR_BUCKET,
17
  PLACEHOLDER_DOMAINS,
 
 
 
 
 
 
18
  )
19
 
20
  router = APIRouter(prefix="/api/auth", tags=["Auth"])
@@ -220,3 +226,29 @@ async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_
220
  except Exception as e:
221
  raise HTTPException(500, f"Failed to update profile: {e}")
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from src.database import get_auth_supabase, get_supabase
10
  from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id
11
  from src.dependencies import get_current_user_id, get_current_user
12
+ from .schemas import ProfileUpdateRequest, EmailRateLimitRequest
13
  from .constants import (
14
  ALLOWED_MIME_TYPES,
15
  MAX_FILE_SIZE_BYTES,
16
  AVATAR_BUCKET,
17
  PLACEHOLDER_DOMAINS,
18
+ EMAIL_RATE_LIMITS,
19
+ )
20
+ from .rate_limiter import (
21
+ enforce_email_rate_limit,
22
+ get_email_rate_limit_status,
23
+ check_and_record_email_rate_limit,
24
  )
25
 
26
  router = APIRouter(prefix="/api/auth", tags=["Auth"])
 
226
  except Exception as e:
227
  raise HTTPException(500, f"Failed to update profile: {e}")
228
 
229
+
230
+ @router.post("/check-email-rate-limit")
231
+ async def check_email_limit(body: EmailRateLimitRequest):
232
+ """
233
+ Enforce in-memory rate limit for email sending actions (3 emails per hour).
234
+ Action must be one of: 'email_verification', 'forgot_password', or 'change_password_confirmation'.
235
+ Raises HTTP 429 if limit is reached.
236
+ """
237
+ remaining = enforce_email_rate_limit(body.action, body.email)
238
+ return {
239
+ "status": "allowed",
240
+ "action": body.action,
241
+ "email": body.email,
242
+ "remaining_attempts": remaining,
243
+ }
244
+
245
+
246
+ @router.get("/email-rate-limit-status")
247
+ async def email_limit_status(action: str, email: str):
248
+ """
249
+ Get the status of an email rate limit window without recording a new attempt.
250
+ """
251
+ status_info = get_email_rate_limit_status(action, email)
252
+ return {"status": "success", "action": action, "email": email, **status_info}
253
+
254
+
auth/schemas.py CHANGED
@@ -9,3 +9,9 @@ class ProfileUpdateRequest(BaseModel):
9
  password: Optional[str] = Field(None, min_length=8)
10
 
11
 
 
 
 
 
 
 
 
9
  password: Optional[str] = Field(None, min_length=8)
10
 
11
 
12
+ class EmailRateLimitRequest(BaseModel):
13
+ email: str
14
+ action: str # 'email_verification', 'forgot_password', or 'change_password_confirmation'
15
+
16
+
17
+
main.py CHANGED
@@ -85,7 +85,7 @@ app = FastAPI(
85
  title="AI Tutor API",
86
  description="Backend API for the AI Tutor for Students application",
87
  version="1.0.0",
88
- lifespan=lifespan,
89
  )
90
 
91
  @app.middleware("http")
 
85
  title="AI Tutor API",
86
  description="Backend API for the AI Tutor for Students application",
87
  version="1.0.0",
88
+ # lifespan=lifespan,
89
  )
90
 
91
  @app.middleware("http")
rag/constants.py CHANGED
@@ -5,16 +5,16 @@ BATCH_WINDOW_S = 0.05
5
  WARMUP_INTERVAL_S = 300
6
 
7
  # Web search configuration — Wiki + DDG for topics, DDG only for PDF/URL materials.
8
- WIKI_TOP_K_RESULTS = 2 # Number of top Wikipedia articles retrieved
9
- WIKI_DOC_CONTENT_CHARS_MAX = 3500 # Max chars from Wikipedia results
10
 
11
  # DuckDuckGO Search
12
- DUCKDUCKGO_NUM_RESULTS = 8 # Number of DDG snippet results returned per search
13
- DUCKDUCKGO_DOC_CONTENT_CHARS_MAX = 6000 # Max chars kept from combined DDG result block
14
 
15
  # RAG & Memory Configuration
16
- MEMORY_WINDOW_SIZE = 20 # Number of previous conversation turns (40 messages) preserved in memory window
17
- TOP_K_CHUNKS = 8 # Number of top relevant material chunks retrieved for context
18
 
19
  RAG_PROMPT_TEMPLATE_BASE = """\
20
  <role>
 
5
  WARMUP_INTERVAL_S = 300
6
 
7
  # Web search configuration — Wiki + DDG for topics, DDG only for PDF/URL materials.
8
+ WIKI_TOP_K_RESULTS = 1 # Number of top Wikipedia articles retrieved
9
+ WIKI_DOC_CONTENT_CHARS_MAX = 1200 # Max chars from Wikipedia result
10
 
11
  # DuckDuckGO Search
12
+ DUCKDUCKGO_NUM_RESULTS = 4 # Number of DDG snippet results returned per search
13
+ DUCKDUCKGO_DOC_CONTENT_CHARS_MAX = 2500 # Max chars kept from combined DDG result block
14
 
15
  # RAG & Memory Configuration
16
+ MEMORY_WINDOW_SIZE = 12 # Number of previous conversation turns preserved in memory window
17
+ TOP_K_CHUNKS = 4 # Number of top relevant material chunks retrieved for context
18
 
19
  RAG_PROMPT_TEMPLATE_BASE = """\
20
  <role>