Spaces:
Running
Running
feat: implement in-memory email rate limiting and adjust RAG search parameters
Browse files- auth/constants.py +7 -0
- auth/rate_limiter.py +164 -0
- auth/routes.py +33 -1
- auth/schemas.py +6 -0
- main.py +1 -1
- 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 =
|
| 9 |
-
WIKI_DOC_CONTENT_CHARS_MAX =
|
| 10 |
|
| 11 |
# DuckDuckGO Search
|
| 12 |
-
DUCKDUCKGO_NUM_RESULTS =
|
| 13 |
-
DUCKDUCKGO_DOC_CONTENT_CHARS_MAX =
|
| 14 |
|
| 15 |
# RAG & Memory Configuration
|
| 16 |
-
MEMORY_WINDOW_SIZE =
|
| 17 |
-
TOP_K_CHUNKS =
|
| 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>
|