Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
DATABASE_URL=postgresql+psycopg://<username>:<password>@<host>/<database>?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
16 changes: 16 additions & 0 deletions app/jwt.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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")
6 changes: 6 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
39 changes: 28 additions & 11 deletions app/routers/otp.py
Original file line number Diff line number Diff line change
@@ -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: 4 OTP requests per 5 minutes
client_ip = get_client_ip(request)
check_otp_rate_limit(phone, client_ip, limit=4, 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=5, window=300)

result = verify_otp(phone, code)

if result["status"] == "approved":
Expand All @@ -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 {
Expand All @@ -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"}
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions app/utils/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -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}")
19 changes: 19 additions & 0 deletions app/utils/redis_conn.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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