From 10032fb24dcf6d668220fd2ab4fb07b9554666db Mon Sep 17 00:00:00 2001 From: MUHSIN-M-P <18muhsinmp@gmail.com> Date: Sun, 27 Jul 2025 15:12:10 +0530 Subject: [PATCH 1/2] added redis - rate limiting and made register endpoint secure --- .env.example | 11 +++++++++++ app/jwt.py | 16 ++++++++++++++++ app/main.py | 6 ++++++ app/routers/otp.py | 39 ++++++++++++++++++++++++++++----------- app/utils/rate_limiter.py | 30 ++++++++++++++++++++++++++++++ app/utils/redis_conn.py | 19 +++++++++++++++++++ requirements.txt | 11 ++++++----- 7 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 .env.example create mode 100644 app/utils/rate_limiter.py create mode 100644 app/utils/redis_conn.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c05b42 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql+psycopg://:@/?sslmode=require&channel_binding=require + +JWT_SECRET_KEY=your_super_secret_jwt_key_here_change_this_in_production + +# Redis (Upstash) +UPSTASH_REDIS_REST_URL=your_upstash_redis_url_here +UPSTASH_REDIS_REST_TOKEN=your_upstash_redis_token_here + +TWILIO_ACCOUNT_SID=your_twilio_account_sid_here +TWILIO_AUTH_TOKEN=your_twilio_auth_token_here +TWILIO_VERIFY_SERVICE_SID=your_twilio_verify_service_sid_here diff --git a/app/jwt.py b/app/jwt.py index ea72bc2..3e3da19 100644 --- a/app/jwt.py +++ b/app/jwt.py @@ -1,5 +1,6 @@ from jose import JWTError, jwt from datetime import datetime, timedelta +from fastapi import Request, HTTPException, status, Depends import os from dotenv import load_dotenv @@ -16,3 +17,18 @@ def create_access_token(data: dict): to_encode.update({"exp": expire}) token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return token + +def verify_token(request: Request): + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid Authorization header") + + token = auth_header.split(" ")[1] + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload # You can return user_id or email from here if encoded + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token") diff --git a/app/main.py b/app/main.py index 3f54164..5ab0bd5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,18 @@ from fastapi import FastAPI from app.routers import otp from app.models.database import init_db +from app.utils.redis_conn import test_redis_connection app = FastAPI() @app.on_event("startup") def startup(): init_db() + + if test_redis_connection(): + print(" Redis connection successful!") + else: + print("Redis connection failed - rate limiting may not work") app.include_router(otp.router) diff --git a/app/routers/otp.py b/app/routers/otp.py index 2499cfe..a162797 100644 --- a/app/routers/otp.py +++ b/app/routers/otp.py @@ -1,16 +1,25 @@ -from fastapi import APIRouter, Query -from app.jwt import create_access_token +from fastapi import APIRouter, Query, Request, Depends +from app.jwt import create_access_token, verify_token from app.auth import verify_otp, send_otp from app.models.database import get_user_by_phone, create_user +from app.utils.rate_limiter import check_otp_rate_limit, get_client_ip router = APIRouter(prefix="/otp", tags=["Otp"]) @router.post("/send-otp") -def send_otp_route(phone: str = Query(...)): +def send_otp_route(request: Request, phone: str = Query(...)): + # Simple rate limiting: 3 OTP requests per 5 minutes + client_ip = get_client_ip(request) + check_otp_rate_limit(phone, client_ip, limit=5, window=300) + return send_otp(phone) @router.post("/verify-otp") -def verify_otp_route(phone: str = Query(...), code: str = Query(...)): +def verify_otp_route(request: Request, phone: str = Query(...), code: str = Query(...)): + # Simple rate limiting: 5 verify attempts per 5 minutes + client_ip = get_client_ip(request) + check_otp_rate_limit(phone, client_ip, limit=8, window=300) + result = verify_otp(phone, code) if result["status"] == "approved": @@ -27,14 +36,15 @@ def verify_otp_route(phone: str = Query(...), code: str = Query(...)): "id": existing_user.id, "phone_number": existing_user.phone_number, "name": existing_user.name, - "email": existing_user.email } } else: + registration_token = create_access_token({"phone": phone}) return { "status": "approved", "message": "OTP verified. Please provide your name and email to complete registration.", - "user_exists": False + "user_exists": False, + "registration_token": registration_token } return { @@ -43,9 +53,19 @@ def verify_otp_route(phone: str = Query(...), code: str = Query(...)): "user_exists": False } -@router.post("/register") -def register_user(phone_number: str, name: str, email: str): +@router.post("/register", summary="Register new user", description="Requires Authorization header with registration token from OTP verification") +def register_user(name: str, email: str, payload: dict = Depends(verify_token)): try: + phone_number = payload.get("phone") + user_id = payload.get("user_id") + + if not phone_number: + return {"status": "error", "message": "Invalid registration token"} + + # Check if token has user_id - existing users cannot register again - prevent registered user adding another users using their token + if user_id: + return {"status": "error", "message": "User already registered. Cannot register again."} + existing_user = get_user_by_phone(phone_number) if existing_user: return {"status": "error", "message": "User already exists with this phone number"} @@ -56,11 +76,8 @@ def register_user(phone_number: str, name: str, email: str): email=email ) - token = create_access_token({"phone": str(phone_number), "user_id": new_user.id}) - return { "status": "registered", - "access_token": token, "message": f"Welcome {new_user.name}! Your account has been created successfully.", "user": { "id": new_user.id, diff --git a/app/utils/rate_limiter.py b/app/utils/rate_limiter.py new file mode 100644 index 0000000..9901451 --- /dev/null +++ b/app/utils/rate_limiter.py @@ -0,0 +1,30 @@ +import time +from fastapi import HTTPException, Request +from app.utils.redis_conn import redis_client + +def get_client_ip(request: Request) -> str: + if hasattr(request.client, "host"): + return request.client.host + return "unknown" + +def check_otp_rate_limit(phone: str, client_ip: str, limit: int = 3, window: int = 300): + try: + key = f"otp_limit:{client_ip}:{phone}" + current_count = redis_client.get(key) + + if current_count and int(current_count) >= limit: + raise HTTPException( + status_code=429, + detail={"error": "Too many OTP requests. Please try again in 5 minutes."} + ) + + # Increment counter + if current_count: + redis_client.incr(key) + else: + redis_client.setex(key, window, 1) # Set with expiry + + except HTTPException: + raise + except Exception as e: + print(f"Rate limiter error: {e}") diff --git a/app/utils/redis_conn.py b/app/utils/redis_conn.py new file mode 100644 index 0000000..381b8ad --- /dev/null +++ b/app/utils/redis_conn.py @@ -0,0 +1,19 @@ +import os +import redis +from dotenv import load_dotenv + +load_dotenv() + +redis_url = os.getenv("REDIS_URL") +if not redis_url: + raise ValueError("REDIS_URL environment variable is not set") + +redis_client = redis.from_url(redis_url, decode_responses=True) + +def test_redis_connection(): + try: + redis_client.ping() + return True + except Exception as e: + print(f"Redis connection failed: {e}") + return False \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index dabdb15..cef8181 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 +fastapi==0.115.0 +uvicorn[standard]==0.32.0 sqlalchemy==2.0.36 psycopg[binary]==3.2.9 python-dotenv==1.0.0 -email-validator==2.1.0 -pydantic==2.5.0 - +email-validator==2.2.0 +pydantic==2.10.0 +python-jose[cryptography]==3.3.0 +redis==5.0.1 twilio==8.10.3 \ No newline at end of file From bfd355efa56febda27b0c4df4a28593af5207bac Mon Sep 17 00:00:00 2001 From: MUHSIN-M-P <18muhsinmp@gmail.com> Date: Sun, 27 Jul 2025 15:21:02 +0530 Subject: [PATCH 2/2] made correct to changes made for development --- app/routers/otp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/routers/otp.py b/app/routers/otp.py index a162797..08681ec 100644 --- a/app/routers/otp.py +++ b/app/routers/otp.py @@ -8,9 +8,9 @@ @router.post("/send-otp") def send_otp_route(request: Request, phone: str = Query(...)): - # Simple rate limiting: 3 OTP requests per 5 minutes + # Simple rate limiting: 4 OTP requests per 5 minutes client_ip = get_client_ip(request) - check_otp_rate_limit(phone, client_ip, limit=5, window=300) + check_otp_rate_limit(phone, client_ip, limit=4, window=300) return send_otp(phone) @@ -18,7 +18,7 @@ def send_otp_route(request: Request, phone: str = Query(...)): def verify_otp_route(request: Request, phone: str = Query(...), code: str = Query(...)): # Simple rate limiting: 5 verify attempts per 5 minutes client_ip = get_client_ip(request) - check_otp_rate_limit(phone, client_ip, limit=8, window=300) + check_otp_rate_limit(phone, client_ip, limit=5, window=300) result = verify_otp(phone, code)