Spaces:
Sleeping
Sleeping
Commit ·
b407a42
1
Parent(s): ef7a502
Add initial implementation of User Management Service
Browse files- Create folder structure and necessary files for the service
- Implement FastAPI application with user authentication and OTP functionality
- Set up MongoDB and Redis configurations
- Add user and OTP models, along with services for user registration and login
- Include utility functions for sending SMS and email OTPs
- Define Pydantic schemas for request validation
- Update requirements.txt with necessary dependencies
- .env +17 -0
- Dockerfile +16 -0
- app/app.py +24 -0
- app/constants/__init__.py +0 -0
- app/core/cache_client.py +26 -0
- app/core/config.py +34 -0
- app/core/nosql_client.py +11 -0
- app/dependencies/__init__.py +0 -0
- app/models/__init__.py +0 -0
- app/models/otp_model.py +59 -0
- app/models/user_model.py +44 -0
- app/repositories/__init__.py +0 -0
- app/routers/profile_router.py +11 -0
- app/routers/user_router.py +29 -0
- app/schemas/__init__.py +0 -0
- app/schemas/user_schema.py +47 -0
- app/services/__init__.py +0 -0
- app/services/otp_service.py +47 -0
- app/services/user_service.py +80 -0
- app/settings.py +11 -0
- app/tests/__init__.py +0 -0
- app/utils/__init__.py +0 -0
- app/utils/common_utils.py +4 -0
- app/utils/email_utils.py +15 -0
- app/utils/jwt.py +14 -0
- app/utils/sms_utils.py +14 -0
- create_ums_structure.sh +22 -0
- requirements.txt +6 -0
.env
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
MONGO_URI=mongodb+srv://insightfy:k0KXafAbV8A8NmQK@cluster0.2shrc.mongodb.net/test??ssl=true&ssl_cert_reqs=CERT_NONE
|
| 3 |
+
|
| 4 |
+
DB_NAME=book-my-service
|
| 5 |
+
|
| 6 |
+
DATABASE_URI=postgresql+asyncpg://trans_owner:BookMyService7@ep-sweet-surf-a1qeduoy.ap-southeast-1.aws.neon.tech/bookmyservice?options=-csearch_path%3Dtrans
|
| 7 |
+
|
| 8 |
+
CACHE_URI=redis-11382.c305.ap-south-1-1.ec2.redns.redis-cloud.com:11382
|
| 9 |
+
|
| 10 |
+
#CACHE_URI=redis-11521.crce182.ap-south-1-1.ec2.redns.redis-cloud.com:11521
|
| 11 |
+
|
| 12 |
+
CACHE_K=dLRZrhU1d5EP9N1CW6grUgsj7MyWIj2i
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
RAZORPAY_KEY_ID=rzp_test_2UTAol2AFSV5VN
|
| 16 |
+
|
| 17 |
+
RAZORPAY_KEY_SECRET=elb4JNjUw3eLqhVMiLFiRgki
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# you will also find guides on how best to write your Dockerfile
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim-buster AS base
|
| 5 |
+
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user . /app
|
| 16 |
+
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/app.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## bookmyservice-ums/app/app.py
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from app.routers import user_router, profile_router
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title="BookMyService User Management Service")
|
| 8 |
+
|
| 9 |
+
app.add_middleware(
|
| 10 |
+
CORSMiddleware,
|
| 11 |
+
allow_origins=["*"],
|
| 12 |
+
allow_credentials=True,
|
| 13 |
+
allow_methods=["*"],
|
| 14 |
+
allow_headers=["*"],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
app.include_router(user_router.router, prefix="/auth", tags=["user_auth"])
|
| 18 |
+
app.include_router(profile_router.router, prefix="/profile", tags=["profile"])
|
| 19 |
+
|
| 20 |
+
@app.get("/")
|
| 21 |
+
def root():
|
| 22 |
+
return {"message": "BookMyService UMS is running"}
|
| 23 |
+
|
| 24 |
+
|
app/constants/__init__.py
ADDED
|
File without changes
|
app/core/cache_client.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from redis.asyncio import Redis
|
| 3 |
+
from redis.exceptions import RedisError
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
# Parse host and port
|
| 9 |
+
CACHE_HOST, CACHE_PORT = settings.CACHE_URI.split(":")
|
| 10 |
+
CACHE_PORT = int(CACHE_PORT)
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
redis_client = Redis(
|
| 14 |
+
host=CACHE_HOST,
|
| 15 |
+
port=CACHE_PORT,
|
| 16 |
+
username="default",
|
| 17 |
+
password=settings.CACHE_K,
|
| 18 |
+
decode_responses=True
|
| 19 |
+
)
|
| 20 |
+
logger.info("Connected to Redis.")
|
| 21 |
+
except RedisError as e:
|
| 22 |
+
logger.error(f"Failed to connect to Redis: {e}")
|
| 23 |
+
raise
|
| 24 |
+
|
| 25 |
+
async def get_redis() -> Redis:
|
| 26 |
+
return redis_client
|
app/core/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
class Settings:
|
| 7 |
+
# MongoDB
|
| 8 |
+
MONGO_URI: str = os.getenv("MONGO_URI")
|
| 9 |
+
DB_NAME: str = os.getenv("DB_NAME")
|
| 10 |
+
|
| 11 |
+
# Redis
|
| 12 |
+
CACHE_URI: str = os.getenv("CACHE_URI")
|
| 13 |
+
CACHE_K: str = os.getenv("CACHE_K")
|
| 14 |
+
|
| 15 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY", "B00Kmyservice@7")
|
| 16 |
+
ALGORITHM: str = os.getenv("ALGORITHM", "HS256")
|
| 17 |
+
|
| 18 |
+
TWILIO_ACCOUNT_SID: str = os.getenv("TWILIO_ACCOUNT_SID")
|
| 19 |
+
TWILIO_AUTH_TOKEN: str = os.getenv("TWILIO_AUTH_TOKEN")
|
| 20 |
+
TWILIO_SMS_FROM: str = os.getenv("TWILIO_SMS_FROM")
|
| 21 |
+
|
| 22 |
+
SMTP_HOST: str = os.getenv("SMTP_HOST")
|
| 23 |
+
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
| 24 |
+
SMTP_USER: str = os.getenv("SMTP_USER")
|
| 25 |
+
SMTP_PASS: str = os.getenv("SMTP_PASS")
|
| 26 |
+
SMTP_FROM: str = os.getenv("SMTP_FROM")
|
| 27 |
+
|
| 28 |
+
def __post_init__(self):
|
| 29 |
+
if not self.MONGO_URI or not self.DB_NAME:
|
| 30 |
+
raise ValueError("MongoDB URI or DB_NAME not configured.")
|
| 31 |
+
if not self.CACHE_URI or not self.CACHE_K:
|
| 32 |
+
raise ValueError("Redis URI or password (CACHE_K) not configured.")
|
| 33 |
+
|
| 34 |
+
settings = Settings()
|
app/core/nosql_client.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
|
| 8 |
+
db = mongo_client[settings.DB_NAME]
|
| 9 |
+
|
| 10 |
+
async def get_mongo_client() -> AsyncIOMotorClient:
|
| 11 |
+
return mongo_client
|
app/dependencies/__init__.py
ADDED
|
File without changes
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/otp_model.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.cache_client import get_redis
|
| 3 |
+
from app.utils.sms_utils import send_sms_otp
|
| 4 |
+
from app.utils.email_utils import send_email_otp
|
| 5 |
+
from app.utils.common_utils import is_email
|
| 6 |
+
|
| 7 |
+
class BookMyServiceOTPModel:
|
| 8 |
+
OTP_TTL = 300 # 5 minutes
|
| 9 |
+
RATE_LIMIT_MAX = 3
|
| 10 |
+
RATE_LIMIT_WINDOW = 600 # 10 minutes
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL):
|
| 14 |
+
redis = await get_redis()
|
| 15 |
+
|
| 16 |
+
# Rate limit: max 3 OTPs per 10 minutes
|
| 17 |
+
rate_key = f"otp_rate_limit:{identifier}"
|
| 18 |
+
attempts = await redis.incr(rate_key)
|
| 19 |
+
if attempts == 1:
|
| 20 |
+
await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
|
| 21 |
+
elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
|
| 22 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
|
| 23 |
+
|
| 24 |
+
# Store OTP
|
| 25 |
+
await redis.setex(f"bms_otp:{identifier}", ttl, otp)
|
| 26 |
+
|
| 27 |
+
# Send OTP via SMS, fallback to Email if identifier is email
|
| 28 |
+
try:
|
| 29 |
+
sid = send_sms_otp(phone, otp)
|
| 30 |
+
print(f"OTP {otp} sent to {phone} via SMS. SID: {sid}")
|
| 31 |
+
except Exception as sms_error:
|
| 32 |
+
print(f"⚠️ SMS failed: {sms_error}")
|
| 33 |
+
if is_email(identifier):
|
| 34 |
+
try:
|
| 35 |
+
send_email_otp(identifier, otp)
|
| 36 |
+
print(f"✅ OTP {otp} sent to {identifier} via email fallback.")
|
| 37 |
+
except Exception as email_error:
|
| 38 |
+
raise HTTPException(status_code=500, detail=f"SMS and email both failed: {email_error}")
|
| 39 |
+
else:
|
| 40 |
+
raise HTTPException(status_code=500, detail="SMS failed and no email fallback available.")
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
async def verify_otp(identifier: str, otp: str):
|
| 44 |
+
redis = await get_redis()
|
| 45 |
+
key = f"bms_otp:{identifier}"
|
| 46 |
+
stored = await redis.get(key)
|
| 47 |
+
if stored and stored == otp:
|
| 48 |
+
await redis.delete(key)
|
| 49 |
+
return True
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
@staticmethod
|
| 53 |
+
async def read_otp(identifier: str):
|
| 54 |
+
redis = await get_redis()
|
| 55 |
+
key = f"bms_otp:{identifier}"
|
| 56 |
+
otp = await redis.get(key)
|
| 57 |
+
if otp:
|
| 58 |
+
return otp
|
| 59 |
+
raise HTTPException(status_code=404, detail="OTP not found or expired")
|
app/models/user_model.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.nosql_client import db
|
| 3 |
+
from app.utils.common_utils import is_email # Assumes utility exists
|
| 4 |
+
|
| 5 |
+
class BookMyServiceUserModel:
|
| 6 |
+
collection = db["book_my_service_users"]
|
| 7 |
+
|
| 8 |
+
@staticmethod
|
| 9 |
+
async def find_by_email(email: str):
|
| 10 |
+
return await BookMyServiceUserModel.collection.find_one({"email": email})
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def find_by_mobile(mobile: str):
|
| 14 |
+
return await BookMyServiceUserModel.collection.find_one({"mobile": mobile})
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def find_by_identifier(identifier: str):
|
| 18 |
+
if is_email(identifier):
|
| 19 |
+
user = await BookMyServiceUserModel.find_by_email(identifier)
|
| 20 |
+
else:
|
| 21 |
+
user = await BookMyServiceUserModel.find_by_mobile(identifier)
|
| 22 |
+
|
| 23 |
+
if not user:
|
| 24 |
+
raise HTTPException(status_code=404, detail="User not found with this email or mobile")
|
| 25 |
+
return user
|
| 26 |
+
|
| 27 |
+
@staticmethod
|
| 28 |
+
async def exists_by_email_or_phone(email: str, phone: str) -> bool:
|
| 29 |
+
return await BookMyServiceUserModel.collection.find_one({
|
| 30 |
+
"$or": [{"email": email}, {"mobile": phone}]
|
| 31 |
+
}) is not None
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
async def create(user_data: dict):
|
| 35 |
+
result = await BookMyServiceUserModel.collection.insert_one(user_data)
|
| 36 |
+
return result.inserted_id
|
| 37 |
+
|
| 38 |
+
@staticmethod
|
| 39 |
+
async def update_by_identifier(identifier: str, update_fields: dict):
|
| 40 |
+
query = {"email": identifier} if is_email(identifier) else {"mobile": identifier}
|
| 41 |
+
result = await BookMyServiceUserModel.collection.update_one(query, {"$set": update_fields})
|
| 42 |
+
if result.matched_count == 0:
|
| 43 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 44 |
+
return result.modified_count > 0
|
app/repositories/__init__.py
ADDED
|
File without changes
|
app/routers/profile_router.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## bookmyservice-ums/app/routers/profile_router.py
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
@router.get("/me")
|
| 9 |
+
def get_profile():
|
| 10 |
+
return {"user": "Sample user profile - implement real logic here"}
|
| 11 |
+
|
app/routers/user_router.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## bookmyservice-ums/app/routers/user_router.py
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from app.schemas.user_schema import OTPRequest, OTPVerifyRequest, UserRegisterRequest, OAuthLoginRequest, TokenResponse
|
| 5 |
+
from app.services.user_service import UserService
|
| 6 |
+
from app.utils.jwt import create_access_token
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.post("/send-otp")
|
| 11 |
+
async def send_otp_handler(payload: OTPRequest):
|
| 12 |
+
identifier = payload.email or payload.phone
|
| 13 |
+
if not identifier:
|
| 14 |
+
raise HTTPException(status_code=400, detail="Email or phone required")
|
| 15 |
+
await UserService.send_otp(identifier, payload.phone or "N/A")
|
| 16 |
+
return {"message": "OTP sent"}
|
| 17 |
+
|
| 18 |
+
@router.post("/verify-otp", response_model=TokenResponse)
|
| 19 |
+
async def verify_otp_handler(payload: OTPVerifyRequest):
|
| 20 |
+
return await UserService.verify_otp_and_login(payload.login_input, payload.otp)
|
| 21 |
+
|
| 22 |
+
@router.post("/oauth-login", response_model=TokenResponse)
|
| 23 |
+
async def oauth_login_handler(payload: OAuthLoginRequest):
|
| 24 |
+
user_id = f"{payload.provider}_{payload.token}" # In production: validate token and extract user info
|
| 25 |
+
return await UserService.verify_otp_and_login(user_id, otp="") # simulate OTP match via token
|
| 26 |
+
|
| 27 |
+
@router.post("/register", response_model=TokenResponse)
|
| 28 |
+
async def register_user(payload: UserRegisterRequest):
|
| 29 |
+
return await UserService.register(payload)
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/user_schema.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from typing import Optional, Literal
|
| 3 |
+
|
| 4 |
+
# Used for OTP-based or OAuth-based user registration
|
| 5 |
+
class UserRegisterRequest(BaseModel):
|
| 6 |
+
full_name: str
|
| 7 |
+
email: Optional[EmailStr] = None
|
| 8 |
+
phone: Optional[str] = None
|
| 9 |
+
otp: Optional[str] = None
|
| 10 |
+
oauth_token: Optional[str] = None
|
| 11 |
+
provider: Optional[Literal["google", "apple"]] = None
|
| 12 |
+
mode: Literal["otp", "oauth"]
|
| 13 |
+
|
| 14 |
+
# Used in login form (optional display name prefilled from local storage)
|
| 15 |
+
class UserLoginRequest(BaseModel):
|
| 16 |
+
name: Optional[str] = None
|
| 17 |
+
email: EmailStr
|
| 18 |
+
|
| 19 |
+
# OTP request via email or phone
|
| 20 |
+
class OTPRequest(BaseModel):
|
| 21 |
+
email: Optional[EmailStr] = None
|
| 22 |
+
phone: Optional[str] = None
|
| 23 |
+
|
| 24 |
+
# Generic OTP request using single login input
|
| 25 |
+
class OTPRequestWithLogin(BaseModel):
|
| 26 |
+
login_input: str # email or phone
|
| 27 |
+
|
| 28 |
+
# OTP verification input
|
| 29 |
+
class OTPVerifyRequest(BaseModel):
|
| 30 |
+
login_input: str
|
| 31 |
+
otp: str
|
| 32 |
+
|
| 33 |
+
# OAuth login using Google/Apple
|
| 34 |
+
class OAuthLoginRequest(BaseModel):
|
| 35 |
+
provider: Literal["google", "apple"]
|
| 36 |
+
token: str
|
| 37 |
+
|
| 38 |
+
# JWT Token response format
|
| 39 |
+
class TokenResponse(BaseModel):
|
| 40 |
+
access_token: str
|
| 41 |
+
token_type: str = "bearer"
|
| 42 |
+
|
| 43 |
+
# Optional: profile info response post-login
|
| 44 |
+
class UserProfileResponse(BaseModel):
|
| 45 |
+
user_id: str
|
| 46 |
+
full_name: str
|
| 47 |
+
email: Optional[EmailStr] = None
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/otp_service.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException
|
| 2 |
+
from app.core.cache_client import get_redis
|
| 3 |
+
from app.utils.sms_utils import send_sms_otp
|
| 4 |
+
from app.utils.email_utils import send_email_otp
|
| 5 |
+
from app.utils.common_utils import is_email
|
| 6 |
+
|
| 7 |
+
class BookMyServiceOTPModel:
|
| 8 |
+
OTP_TTL = 300 # 5 minutes
|
| 9 |
+
RATE_LIMIT_MAX = 3
|
| 10 |
+
RATE_LIMIT_WINDOW = 600 # 10 minutes
|
| 11 |
+
|
| 12 |
+
@staticmethod
|
| 13 |
+
async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL):
|
| 14 |
+
redis = await get_redis()
|
| 15 |
+
|
| 16 |
+
rate_key = f"otp_rate_limit:{identifier}"
|
| 17 |
+
attempts = await redis.incr(rate_key)
|
| 18 |
+
if attempts == 1:
|
| 19 |
+
await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
|
| 20 |
+
elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
|
| 21 |
+
raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
|
| 22 |
+
|
| 23 |
+
await redis.setex(f"bms_otp:{identifier}", ttl, otp)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
sid = send_sms_otp(phone, otp)
|
| 27 |
+
print(f"✅ OTP {otp} sent to {phone}. SID: {sid}")
|
| 28 |
+
except Exception as sms_error:
|
| 29 |
+
print(f"⚠️ SMS failed: {sms_error}")
|
| 30 |
+
if is_email(identifier):
|
| 31 |
+
try:
|
| 32 |
+
send_email_otp(identifier, otp)
|
| 33 |
+
print(f"✅ OTP {otp} sent to {identifier} via email fallback.")
|
| 34 |
+
except Exception as email_error:
|
| 35 |
+
raise HTTPException(status_code=500, detail=f"SMS and email both failed: {email_error}")
|
| 36 |
+
else:
|
| 37 |
+
raise HTTPException(status_code=500, detail="SMS failed and no fallback available.")
|
| 38 |
+
|
| 39 |
+
@staticmethod
|
| 40 |
+
async def verify_otp(identifier: str, otp: str):
|
| 41 |
+
redis = await get_redis()
|
| 42 |
+
key = f"bms_otp:{identifier}"
|
| 43 |
+
stored = await redis.get(key)
|
| 44 |
+
if stored and stored == otp:
|
| 45 |
+
await redis.delete(key)
|
| 46 |
+
return True
|
| 47 |
+
return False
|
app/services/user_service.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from jose import jwt
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from fastapi import HTTPException
|
| 5 |
+
from app.models.user_model import BookMyServiceUserModel
|
| 6 |
+
from app.models.otp_model import BookMyServiceOTPModel
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
from app.utils.common_utils import is_email
|
| 9 |
+
from app.schemas.user_schema import UserRegisterRequest
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("user_service")
|
| 13 |
+
|
| 14 |
+
class UserService:
|
| 15 |
+
@staticmethod
|
| 16 |
+
async def send_otp(identifier: str, phone: str):
|
| 17 |
+
otp = str(random.randint(100000, 999999))
|
| 18 |
+
await BookMyServiceOTPModel.store_otp(identifier, phone, otp)
|
| 19 |
+
logger.debug(f"OTP sent to {identifier}: {otp}")
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
async def verify_otp_and_login(identifier: str, otp: str):
|
| 23 |
+
if not await BookMyServiceOTPModel.verify_otp(identifier, otp):
|
| 24 |
+
logger.debug(f"Invalid or expired OTP for identifier: {identifier}")
|
| 25 |
+
raise HTTPException(status_code=400, detail="Invalid or expired OTP")
|
| 26 |
+
|
| 27 |
+
user = await BookMyServiceUserModel.find_by_identifier(identifier)
|
| 28 |
+
if not user:
|
| 29 |
+
logger.debug(f"No user found for identifier: {identifier}")
|
| 30 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 31 |
+
|
| 32 |
+
token_data = {
|
| 33 |
+
"sub": user.get("user_id"),
|
| 34 |
+
"user_id": user.get("user_id"),
|
| 35 |
+
"email": user.get("email"),
|
| 36 |
+
"role": "user",
|
| 37 |
+
"exp": datetime.utcnow() + timedelta(hours=8)
|
| 38 |
+
}
|
| 39 |
+
access_token = jwt.encode(token_data, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 40 |
+
return {"access_token": access_token}
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
async def register(data: UserRegisterRequest):
|
| 44 |
+
if data.mode == "otp":
|
| 45 |
+
identifier = data.email or data.phone
|
| 46 |
+
if not data.otp or not identifier:
|
| 47 |
+
raise HTTPException(status_code=400, detail="OTP and email/phone required")
|
| 48 |
+
if not await BookMyServiceOTPModel.verify_otp(identifier, data.otp):
|
| 49 |
+
raise HTTPException(status_code=400, detail="Invalid or expired OTP")
|
| 50 |
+
user_id = f"otp_{identifier}"
|
| 51 |
+
|
| 52 |
+
elif data.mode == "oauth":
|
| 53 |
+
if not data.oauth_token or not data.provider:
|
| 54 |
+
raise HTTPException(status_code=400, detail="OAuth token and provider required")
|
| 55 |
+
user_id = f"{data.provider}_{data.oauth_token}"
|
| 56 |
+
|
| 57 |
+
else:
|
| 58 |
+
raise HTTPException(status_code=400, detail="Unsupported registration mode")
|
| 59 |
+
|
| 60 |
+
if await BookMyServiceUserModel.collection.find_one({"user_id": user_id}):
|
| 61 |
+
raise HTTPException(status_code=409, detail="User already registered")
|
| 62 |
+
|
| 63 |
+
user_doc = {
|
| 64 |
+
"user_id": user_id,
|
| 65 |
+
"full_name": data.full_name,
|
| 66 |
+
"email": data.email,
|
| 67 |
+
"phone": data.phone,
|
| 68 |
+
"created_at": datetime.utcnow()
|
| 69 |
+
}
|
| 70 |
+
await BookMyServiceUserModel.collection.insert_one(user_doc)
|
| 71 |
+
|
| 72 |
+
token_data = {
|
| 73 |
+
"sub": user_id,
|
| 74 |
+
"user_id": user_id,
|
| 75 |
+
"email": data.email,
|
| 76 |
+
"role": "user",
|
| 77 |
+
"exp": datetime.utcnow() + timedelta(hours=8)
|
| 78 |
+
}
|
| 79 |
+
access_token = jwt.encode(token_data, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 80 |
+
return {"access_token": access_token}
|
app/settings.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## bookmyservice-ums/settings.py
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseSettings
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
SECRET_KEY: str = "secret-key-placeholder"
|
| 8 |
+
ALGORITHM: str = "HS256"
|
| 9 |
+
|
| 10 |
+
settings = Settings()
|
| 11 |
+
|
app/tests/__init__.py
ADDED
|
File without changes
|
app/utils/__init__.py
ADDED
|
File without changes
|
app/utils/common_utils.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
def is_email(identifier: str) -> bool:
|
| 4 |
+
return re.match(r"[^@]+@[^@]+\.[^@]+", identifier) is not None
|
app/utils/email_utils.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import smtplib
|
| 2 |
+
from email.mime.text import MIMEText
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
async def send_email_otp(to_email: str, otp: str):
|
| 6 |
+
msg = MIMEText(f"Your OTP is {otp}. It is valid for 5 minutes.")
|
| 7 |
+
msg["Subject"] = "Your One-Time Password"
|
| 8 |
+
msg["From"] = settings.SMTP_FROM
|
| 9 |
+
msg["To"] = to_email
|
| 10 |
+
|
| 11 |
+
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
| 12 |
+
server.starttls()
|
| 13 |
+
server.login(settings.SMTP_USER, settings.SMTP_PASS)
|
| 14 |
+
server.send_message(msg)
|
| 15 |
+
server.quit()
|
app/utils/jwt.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
## bookmyservice-ums/app/utils/jwt.py
|
| 3 |
+
|
| 4 |
+
from jose import jwt
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
SECRET_KEY = "secret-key-placeholder"
|
| 8 |
+
ALGORITHM = "HS256"
|
| 9 |
+
|
| 10 |
+
def create_access_token(data: dict, expires_minutes: int = 60):
|
| 11 |
+
to_encode = data.copy()
|
| 12 |
+
expire = datetime.utcnow() + timedelta(minutes=expires_minutes)
|
| 13 |
+
to_encode.update({"exp": expire})
|
| 14 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
app/utils/sms_utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from twilio.rest import Client
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
def send_sms_otp(phone: str, otp: str) -> str:
|
| 6 |
+
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
|
| 7 |
+
|
| 8 |
+
message = client.messages.create(
|
| 9 |
+
from_=settings.TWILIO_SMS_FROM,
|
| 10 |
+
body=f"Your OTP is {otp}",
|
| 11 |
+
to=phone
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
return message.sid
|
create_ums_structure.sh
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Base directory (change this as needed)
|
| 4 |
+
BASE_DIR="bookmyservice-ums/app"
|
| 5 |
+
|
| 6 |
+
# Create folder structure
|
| 7 |
+
mkdir -p $BASE_DIR/models
|
| 8 |
+
mkdir -p $BASE_DIR/schemas
|
| 9 |
+
mkdir -p $BASE_DIR/routers
|
| 10 |
+
mkdir -p $BASE_DIR/services
|
| 11 |
+
mkdir -p $BASE_DIR/repositories
|
| 12 |
+
mkdir -p $BASE_DIR/utils
|
| 13 |
+
mkdir -p $BASE_DIR/constants
|
| 14 |
+
mkdir -p $BASE_DIR/dependencies
|
| 15 |
+
mkdir -p $BASE_DIR/tests
|
| 16 |
+
|
| 17 |
+
# Add __init__.py to make each a Python package
|
| 18 |
+
for dir in models schemas routers services repositories utils constants dependencies tests; do
|
| 19 |
+
touch "$BASE_DIR/$dir/__init__.py"
|
| 20 |
+
done
|
| 21 |
+
|
| 22 |
+
echo "📁 Folder structure for User Management Service created under $BASE_DIR"
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
fastapi
|
| 3 |
+
uvicorn
|
| 4 |
+
python-jose
|
| 5 |
+
pydantic
|
| 6 |
+
twilio
|