Skip to content
Open
31 changes: 31 additions & 0 deletions backend/app/auth/ratelimit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import time
from collections import defaultdict

from fastapi import HTTPException, Request, status

# In-memory storage for rate limiting
# In a production environment, you might want to use Redis or another shared storage.
Comment on lines +6 to +7
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add cleanup mechanism for production readiness.

The comment correctly identifies the need for Redis in production, but the current implementation lacks any cleanup mechanism for stale entries.

Consider adding a cleanup mechanism:

+import threading
+from datetime import datetime, timedelta
+
+# Cleanup stale entries periodically
+def cleanup_stale_entries():
+    current_time = time.time()
+    with _rate_limit_lock:
+        stale_ips = [
+            ip for ip, data in rate_limit_data.items()
+            if current_time - data["timestamp"] > RATE_LIMIT_DURATION * 2
+        ]
+        for ip in stale_ips:
+            del rate_limit_data[ip]

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/app/auth/ratelimit.py around lines 5 to 6, the comment notes the use
of in-memory storage for rate limiting but lacks a cleanup mechanism for stale
entries. To fix this, implement a cleanup process that periodically removes
outdated or expired rate limit records from the in-memory store to prevent
memory bloat. This can be done by tracking timestamps for entries and scheduling
a cleanup function to purge stale data at regular intervals.

rate_limit_data = defaultdict(lambda: {"count": 0, "timestamp": 0})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical memory leak and thread safety issues.

The defaultdict will grow indefinitely without cleanup, causing memory leaks in production. Additionally, the implementation is not thread-safe for concurrent requests from the same IP.

Apply this diff to add basic cleanup and improve thread safety:

-rate_limit_data = defaultdict(lambda: {"count": 0, "timestamp": 0})
+import threading
+from collections import defaultdict
+
+_rate_limit_lock = threading.Lock()
+rate_limit_data = defaultdict(lambda: {"count": 0, "timestamp": 0})

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/app/auth/ratelimit.py at line 7, the use of defaultdict to store rate
limit data causes unbounded memory growth and is not thread-safe. To fix this,
implement a cleanup mechanism that periodically removes stale entries from
rate_limit_data to prevent memory leaks, and protect access to rate_limit_data
with a threading lock or other synchronization method to ensure thread safety
during concurrent requests.

RATE_LIMIT_DURATION = 60 # seconds
RATE_LIMIT_REQUESTS = 5 # requests


def rate_limiter(request: Request):
"""
Rate limiting dependency to prevent brute force attacks.
"""
client_ip = request.client.host
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

IP spoofing vulnerability.

Using request.client.host is vulnerable to IP spoofing through X-Forwarded-For headers. Consider using request.headers.get("x-real-ip") or implementing proper proxy IP detection.

🤖 Prompt for AI Agents
In backend/app/auth/ratelimit.py at line 15, replace the usage of
request.client.host with a safer method to obtain the client IP address. Instead
of directly using request.client.host, retrieve the IP from the "x-real-ip"
header using request.headers.get("x-real-ip") or implement logic to properly
detect the real client IP behind proxies to prevent IP spoofing vulnerabilities.

current_time = time.time()

if current_time - rate_limit_data[client_ip]["timestamp"] > RATE_LIMIT_DURATION:
# Reset counter if duration has passed
rate_limit_data[client_ip]["count"] = 1
rate_limit_data[client_ip]["timestamp"] = current_time
else:
rate_limit_data[client_ip]["count"] += 1

Comment on lines +20 to +26
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Logic error in counter reset and missing thread safety.

The counter is set to 1 instead of 0 when resetting, causing an off-by-one error. The first request after reset is incorrectly counted as the second request.

Apply this diff to fix the logic and add thread safety:

+    with _rate_limit_lock:
         if current_time - rate_limit_data[client_ip]["timestamp"] > RATE_LIMIT_DURATION:
             # Reset counter if duration has passed
-            rate_limit_data[client_ip]["count"] = 1
+            rate_limit_data[client_ip]["count"] = 0
             rate_limit_data[client_ip]["timestamp"] = current_time
-        else:
-            rate_limit_data[client_ip]["count"] += 1
+        
+        rate_limit_data[client_ip]["count"] += 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if current_time - rate_limit_data[client_ip]["timestamp"] > RATE_LIMIT_DURATION:
# Reset counter if duration has passed
rate_limit_data[client_ip]["count"] = 1
rate_limit_data[client_ip]["timestamp"] = current_time
else:
rate_limit_data[client_ip]["count"] += 1
with _rate_limit_lock:
if current_time - rate_limit_data[client_ip]["timestamp"] > RATE_LIMIT_DURATION:
# Reset counter if duration has passed
rate_limit_data[client_ip]["count"] = 0
rate_limit_data[client_ip]["timestamp"] = current_time
rate_limit_data[client_ip]["count"] += 1
🤖 Prompt for AI Agents
In backend/app/auth/ratelimit.py around lines 18 to 24, the rate limit counter
is incorrectly reset to 1 instead of 0, causing an off-by-one error. Change the
reset value to 0 so the first request after reset is counted correctly.
Additionally, add thread safety by using a lock or synchronization mechanism
around the code that reads and updates rate_limit_data to prevent race
conditions in concurrent environments.

if rate_limit_data[client_ip]["count"] > RATE_LIMIT_REQUESTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many requests. Please try again later.",
)
76 changes: 15 additions & 61 deletions backend/app/auth/routes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import timedelta

from app.auth.ratelimit import rate_limiter
from app.auth.schemas import (
AuthResponse,
EmailLoginRequest,
Expand All @@ -14,32 +15,23 @@
TokenVerifyRequest,
UserResponse,
)
from app.auth.security import create_access_token, oauth2_scheme # Import oauth2_scheme
from app.auth.security import create_access_token, oauth2_scheme
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Remove unused imports

The static analysis correctly identified unused imports that should be removed:

  • oauth2_scheme on line 18
  • Request on line 21
-from app.auth.security import create_access_token, oauth2_scheme
+from app.auth.security import create_access_token
 from app.auth.service import auth_service
 from app.config import settings
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import APIRouter, Depends, HTTPException, status

Also applies to: 21-21

🧰 Tools
🪛 Ruff (0.12.2)

18-18: app.auth.security.oauth2_scheme imported but unused

Remove unused import: app.auth.security.oauth2_scheme

(F401)

🤖 Prompt for AI Agents
In backend/app/auth/routes.py at lines 18 and 21, remove the unused imports
oauth2_scheme and Request respectively to clean up the code and avoid
unnecessary imports.

from app.auth.service import auth_service
from app.config import settings
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import ( # Import OAuth2PasswordRequestForm
OAuth2PasswordRequestForm,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm

router = APIRouter(prefix="/auth", tags=["Authentication"])


@router.post(
"/token", response_model=TokenResponse, include_in_schema=False
) # include_in_schema=False to hide from docs if desired, or True to show
@router.post("/token", response_model=TokenResponse, include_in_schema=False)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix function call in argument defaults

Using Depends() in argument defaults can cause issues. The dependency should be resolved at call time, not at function definition time.

Apply this diff to fix the issue:

-async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
+async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(OAuth2PasswordRequestForm)):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(OAuth2PasswordRequestForm)):
🧰 Tools
🪛 Ruff (0.12.2)

28-28: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
In backend/app/auth/routes.py at line 28, the use of Depends() as a default
argument value causes the dependency to be resolved at function definition time
instead of call time. To fix this, remove the parentheses from Depends so that
the argument is declared as form_data: OAuth2PasswordRequestForm = Depends
without calling it. This ensures the dependency is properly injected when the
function is called.

"""
OAuth2 compatible token login, get an access token for future requests.
This endpoint is used by Swagger UI for authorization.
It expects username (email) and password in form-data.
"""
try:
# Note: OAuth2PasswordRequestForm uses 'username' field for the user identifier.
# We'll treat it as email here.
result = await auth_service.authenticate_user_with_email(
email=form_data.username, # form_data.username is the email
password=form_data.password,
email=form_data.username, password=form_data.password
)

access_token = create_access_token(
Expand All @@ -51,39 +43,29 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
except HTTPException:
raise
except Exception as e:
# It's good practice to log the exception here
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Authentication failed: {str(e)}",
)


@router.post("/signup/email", response_model=AuthResponse)
@router.post(
"/signup/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]
)
async def signup_with_email(request: EmailSignupRequest):
"""
Registers a new user using email, password, and name, and returns authentication tokens and user information.

Args:
request: Contains the user's email, password, and name for registration.

Returns:
An AuthResponse with access token, refresh token, and user details.

Raises:
HTTPException: If registration fails or an unexpected error occurs.
Registers a new user using email, password, and name.
"""
try:
result = await auth_service.create_user_with_email(
email=request.email, password=request.password, name=request.name
)

# Create access token
access_token = create_access_token(
data={"sub": str(result["user"]["_id"])},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)

# Convert ObjectId to string for response
result["user"]["_id"] = str(result["user"]["_id"])

return AuthResponse(
Expand All @@ -100,25 +82,23 @@ async def signup_with_email(request: EmailSignupRequest):
)


@router.post("/login/email", response_model=AuthResponse)
@router.post(
"/login/email", response_model=AuthResponse, dependencies=[Depends(rate_limiter)]
)
async def login_with_email(request: EmailLoginRequest):
"""
Authenticates a user using email and password credentials.

On successful authentication, returns an access token, refresh token, and user information. Raises an HTTP 500 error if authentication fails due to an unexpected error.
"""
try:
result = await auth_service.authenticate_user_with_email(
email=request.email, password=request.password
)

# Create access token
access_token = create_access_token(
data={"sub": str(result["user"]["_id"])},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)

# Convert ObjectId to string for response
result["user"]["_id"] = str(result["user"]["_id"])

return AuthResponse(
Expand All @@ -139,19 +119,15 @@ async def login_with_email(request: EmailLoginRequest):
async def login_with_google(request: GoogleLoginRequest):
"""
Authenticates or registers a user using a Google OAuth ID token.

On success, returns an access token, refresh token, and user information. Raises an HTTP 500 error if Google authentication fails.
"""
try:
result = await auth_service.authenticate_with_google(request.id_token)

# Create access token
access_token = create_access_token(
data={"sub": str(result["user"]["_id"])},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)

# Convert ObjectId to string for response
result["user"]["_id"] = str(result["user"]["_id"])

return AuthResponse(
Expand All @@ -172,18 +148,12 @@ async def login_with_google(request: GoogleLoginRequest):
async def refresh_token(request: RefreshTokenRequest):
"""
Refreshes JWT tokens using a valid refresh token.

Validates the provided refresh token, issues a new access token and refresh token if valid, and returns them. Raises a 401 error if the refresh token is invalid or revoked.

Returns:
A TokenResponse containing the new access and refresh tokens.
"""
try:
new_refresh_token = await auth_service.refresh_access_token(
request.refresh_token
)

# Get user from the new refresh token to create access token
from app.database import get_database

db = get_database()
Expand All @@ -196,7 +166,7 @@ async def refresh_token(request: RefreshTokenRequest):
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to create new tokens",
)
# Create new access token

access_token = create_access_token(
data={"sub": str(token_record["user_id"])},
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
Expand All @@ -216,14 +186,10 @@ async def refresh_token(request: RefreshTokenRequest):
async def verify_token(request: TokenVerifyRequest):
"""
Verifies an access token and returns the associated user information.

Raises:
HTTPException: If the token is invalid or expired, returns a 401 Unauthorized error.
"""
try:
user = await auth_service.verify_access_token(request.access_token)

# Convert ObjectId to string for response
user["_id"] = str(user["_id"])

return UserResponse(**user)
Expand All @@ -238,10 +204,7 @@ async def verify_token(request: TokenVerifyRequest):
@router.post("/password/reset/request", response_model=SuccessResponse)
async def request_password_reset(request: PasswordResetRequest):
"""
Initiates a password reset process by sending a reset link to the provided email address.

Returns:
SuccessResponse: Indicates whether the password reset email was sent if the email exists.
Initiates a password reset process by sending a reset link to the provided email.
"""
try:
await auth_service.request_password_reset(request.email)
Expand All @@ -259,15 +222,6 @@ async def request_password_reset(request: PasswordResetRequest):
async def confirm_password_reset(request: PasswordResetConfirm):
"""
Resets a user's password using a valid password reset token.

Args:
request: Contains the password reset token and the new password.

Returns:
SuccessResponse indicating the password has been reset successfully.

Raises:
HTTPException: If the reset token is invalid or an error occurs during the reset process.
"""
try:
await auth_service.confirm_password_reset(
Expand Down
35 changes: 29 additions & 6 deletions backend/app/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import re
from datetime import datetime
from typing import Optional

from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, validator


# Request Models
class EmailSignupRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=6)
name: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
name: str = Field(..., min_length=1, max_length=100)

@validator("password")
def password_complexity(cls, v):
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain at least one uppercase letter")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain at least one lowercase letter")
if not re.search(r"[0-9]", v):
raise ValueError("Password must contain at least one digit")
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v):
raise ValueError("Password must contain at least one special character")
return v


class EmailLoginRequest(BaseModel):
Expand All @@ -30,14 +42,25 @@ class PasswordResetRequest(BaseModel):

class PasswordResetConfirm(BaseModel):
reset_token: str
new_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=8)

@validator("new_password")
def password_complexity(cls, v):
if not re.search(r"[A-Z]", v):
raise ValueError("Password must contain at least one uppercase letter")
if not re.search(r"[a-z]", v):
raise ValueError("Password must contain at least one lowercase letter")
if not re.search(r"[0-9]", v):
raise ValueError("Password must contain at least one digit")
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', v):
raise ValueError("Password must contain at least one special character")
return v


class TokenVerifyRequest(BaseModel):
access_token: str


# Response Models
class UserResponse(BaseModel):
id: str = Field(alias="_id")
email: str
Expand Down
Loading
Loading