Spaces:
Sleeping
Sleeping
Commit ·
8ee3f02
1
Parent(s): 3eb72ad
Refactor user model and service; implement OAuth and OTP login features, enhance JWT handling, and update requirements
Browse files- app/models/user_model.py +5 -3
- app/routers/user_router.py +79 -12
- app/schemas/user_schema.py +1 -1
- app/services/user_service.py +1 -1
- app/utils/jwt.py +12 -2
- app/utils/social_utils.py +70 -0
- requirements.txt +3 -1
app/models/user_model.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 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["
|
| 7 |
|
| 8 |
@staticmethod
|
| 9 |
async def find_by_email(email: str):
|
|
@@ -31,8 +32,9 @@ class BookMyServiceUserModel:
|
|
| 31 |
}) is not None
|
| 32 |
|
| 33 |
@staticmethod
|
| 34 |
-
async def create(user_data:
|
| 35 |
-
|
|
|
|
| 36 |
return result.inserted_id
|
| 37 |
|
| 38 |
@staticmethod
|
|
|
|
| 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 |
+
from app.schemas.user_schema import UserRegisterRequest
|
| 5 |
|
| 6 |
class BookMyServiceUserModel:
|
| 7 |
+
collection = db["customers"]
|
| 8 |
|
| 9 |
@staticmethod
|
| 10 |
async def find_by_email(email: str):
|
|
|
|
| 32 |
}) is not None
|
| 33 |
|
| 34 |
@staticmethod
|
| 35 |
+
async def create(user_data: UserRegisterRequest):
|
| 36 |
+
user_dict = user_data.dict()
|
| 37 |
+
result = await BookMyServiceUserModel.collection.insert_one(user_dict)
|
| 38 |
return result.inserted_id
|
| 39 |
|
| 40 |
@staticmethod
|
app/routers/user_router.py
CHANGED
|
@@ -1,29 +1,96 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
| 3 |
-
from
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.services.user_service import UserService
|
| 6 |
-
from app.utils.jwt import
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
| 22 |
@router.post("/oauth-login", response_model=TokenResponse)
|
| 23 |
async def oauth_login_handler(payload: OAuthLoginRequest):
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
|
|
|
| 27 |
@router.post("/register", response_model=TokenResponse)
|
| 28 |
-
async def register_user(
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Security
|
| 2 |
+
from fastapi.security import APIKeyHeader
|
| 3 |
|
| 4 |
+
from app.schemas.user_schema import (
|
| 5 |
+
OTPRequest,
|
| 6 |
+
OTPVerifyRequest,
|
| 7 |
+
UserRegisterRequest,
|
| 8 |
+
OAuthLoginRequest,
|
| 9 |
+
TokenResponse,
|
| 10 |
+
)
|
| 11 |
from app.services.user_service import UserService
|
| 12 |
+
from app.utils.jwt import create_temp_token, decode_token
|
| 13 |
+
from app.utils.social_utils import verify_google_token, verify_apple_token
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
| 17 |
+
# 🔐 Declare API key header scheme (Swagger shows a single token input box)
|
| 18 |
+
api_key_scheme = APIKeyHeader(name="Authorization", auto_error=True)
|
| 19 |
+
|
| 20 |
+
# 🔍 Bearer token parser
|
| 21 |
+
# More flexible bearer token parser
|
| 22 |
+
def get_bearer_token(api_key: str = Security(api_key_scheme)) -> str:
|
| 23 |
+
try:
|
| 24 |
+
# If "Bearer " prefix is included, strip it
|
| 25 |
+
if api_key.lower().startswith("bearer "):
|
| 26 |
+
return api_key[7:] # Remove "Bearer " prefix
|
| 27 |
+
|
| 28 |
+
# Else, assume it's already a raw JWT
|
| 29 |
+
return api_key
|
| 30 |
+
|
| 31 |
+
except Exception as e:
|
| 32 |
+
raise HTTPException(
|
| 33 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 34 |
+
detail="Invalid or missing Authorization token"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
@router.post("/send-otp")
|
| 40 |
async def send_otp_handler(payload: OTPRequest):
|
| 41 |
identifier = payload.email or payload.phone
|
| 42 |
if not identifier:
|
| 43 |
raise HTTPException(status_code=400, detail="Email or phone required")
|
| 44 |
+
|
| 45 |
await UserService.send_otp(identifier, payload.phone or "N/A")
|
| 46 |
+
|
| 47 |
+
temp_token = create_temp_token({
|
| 48 |
+
"sub": identifier,
|
| 49 |
+
"type": "otp_verification"
|
| 50 |
+
}, expires_minutes=10)
|
| 51 |
+
|
| 52 |
+
return {"message": "OTP sent", "temp_token": temp_token}
|
| 53 |
|
| 54 |
+
# 🔐 OTP Login using temporary token
|
| 55 |
+
@router.post("/otp-login", response_model=TokenResponse)
|
| 56 |
+
async def otp_login_handler(
|
| 57 |
+
payload: OTPVerifyRequest,
|
| 58 |
+
temp_token: str = Depends(get_bearer_token)
|
| 59 |
+
):
|
| 60 |
+
decoded = decode_token(temp_token)
|
| 61 |
+
if not decoded or decoded.get("sub") != payload.login_input or decoded.get("type") != "otp_verification":
|
| 62 |
+
raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
|
| 63 |
+
|
| 64 |
+
return await UserService.otp_login_handler(payload.login_input, payload.otp)
|
| 65 |
|
| 66 |
+
# 🌐 OAuth Login for Google / Apple
|
| 67 |
@router.post("/oauth-login", response_model=TokenResponse)
|
| 68 |
async def oauth_login_handler(payload: OAuthLoginRequest):
|
| 69 |
+
if payload.provider == "google":
|
| 70 |
+
user_info = await verify_google_token(payload.token)
|
| 71 |
+
user_id = f"google_{user_info['id']}"
|
| 72 |
+
elif payload.provider == "apple":
|
| 73 |
+
user_info = await verify_apple_token(payload.token)
|
| 74 |
+
user_id = f"apple_{user_info['id']}"
|
| 75 |
+
else:
|
| 76 |
+
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
| 77 |
+
|
| 78 |
+
temp_token = create_temp_token({
|
| 79 |
+
"sub": user_id,
|
| 80 |
+
"type": "oauth_session",
|
| 81 |
+
"verified": True
|
| 82 |
+
}, expires_minutes=10)
|
| 83 |
+
|
| 84 |
+
return {"access_token": temp_token, "token_type": "bearer"}
|
| 85 |
|
| 86 |
+
# 👤 Final user registration after OTP or OAuth
|
| 87 |
@router.post("/register", response_model=TokenResponse)
|
| 88 |
+
async def register_user(
|
| 89 |
+
payload: UserRegisterRequest,
|
| 90 |
+
temp_token: str = Depends(get_bearer_token)
|
| 91 |
+
):
|
| 92 |
+
decoded = decode_token(temp_token)
|
| 93 |
+
if not decoded or decoded.get("type") not in ["otp_verification", "oauth_session"]:
|
| 94 |
+
raise HTTPException(status_code=401, detail="Invalid or expired registration token")
|
| 95 |
+
|
| 96 |
+
return await UserService.register(payload, decoded)
|
app/schemas/user_schema.py
CHANGED
|
@@ -3,7 +3,7 @@ from typing import Optional, Literal
|
|
| 3 |
|
| 4 |
# Used for OTP-based or OAuth-based user registration
|
| 5 |
class UserRegisterRequest(BaseModel):
|
| 6 |
-
|
| 7 |
email: Optional[EmailStr] = None
|
| 8 |
phone: Optional[str] = None
|
| 9 |
otp: Optional[str] = None
|
|
|
|
| 3 |
|
| 4 |
# Used for OTP-based or OAuth-based user registration
|
| 5 |
class UserRegisterRequest(BaseModel):
|
| 6 |
+
name: str
|
| 7 |
email: Optional[EmailStr] = None
|
| 8 |
phone: Optional[str] = None
|
| 9 |
otp: Optional[str] = None
|
app/services/user_service.py
CHANGED
|
@@ -19,7 +19,7 @@ class UserService:
|
|
| 19 |
logger.debug(f"OTP sent to {identifier}: {otp}")
|
| 20 |
|
| 21 |
@staticmethod
|
| 22 |
-
async def
|
| 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")
|
|
|
|
| 19 |
logger.debug(f"OTP sent to {identifier}: {otp}")
|
| 20 |
|
| 21 |
@staticmethod
|
| 22 |
+
async def otp_login_handler(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")
|
app/utils/jwt.py
CHANGED
|
@@ -1,14 +1,24 @@
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
|
| 2 |
## bookmyservice-ums/app/utils/jwt.py
|
| 3 |
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
from datetime import datetime, timedelta
|
| 6 |
|
| 7 |
SECRET_KEY = "secret-key-placeholder"
|
| 8 |
ALGORITHM = "HS256"
|
| 9 |
|
| 10 |
+
|
| 11 |
def create_access_token(data: dict, expires_minutes: int = 60):
|
| 12 |
to_encode = data.copy()
|
| 13 |
expire = datetime.utcnow() + timedelta(minutes=expires_minutes)
|
| 14 |
to_encode.update({"exp": expire})
|
| 15 |
+
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 16 |
+
|
| 17 |
+
def create_temp_token(data: dict, expires_minutes: int = 10):
|
| 18 |
+
return create_access_token(data, expires_minutes=expires_minutes)
|
| 19 |
+
|
| 20 |
+
def decode_token(token: str) -> dict:
|
| 21 |
+
try:
|
| 22 |
+
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 23 |
+
except JWTError:
|
| 24 |
+
return {}
|
app/utils/social_utils.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from google.oauth2 import id_token as google_id_token
|
| 2 |
+
from google.auth.transport import requests as google_requests
|
| 3 |
+
from jose import jwt as jose_jwt, JWTError, jwk
|
| 4 |
+
from jose.utils import base64url_decode
|
| 5 |
+
from typing import Dict
|
| 6 |
+
import httpx
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
# Async wrapper to run Google’s sync function in a thread pool
|
| 10 |
+
import asyncio
|
| 11 |
+
from functools import partial
|
| 12 |
+
|
| 13 |
+
# ✅ Async Google token verification
|
| 14 |
+
async def verify_google_token(token: str, client_id: str) -> Dict:
|
| 15 |
+
"""
|
| 16 |
+
Asynchronously verifies a Google ID token and returns the payload if valid.
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
loop = asyncio.get_event_loop()
|
| 20 |
+
# Run the sync method in a thread to avoid blocking
|
| 21 |
+
idinfo = await loop.run_in_executor(
|
| 22 |
+
None,
|
| 23 |
+
partial(google_id_token.verify_oauth2_token, token, google_requests.Request(), client_id)
|
| 24 |
+
)
|
| 25 |
+
if idinfo.get('iss') not in ['accounts.google.com', 'https://accounts.google.com']:
|
| 26 |
+
raise ValueError('Wrong issuer.')
|
| 27 |
+
return idinfo
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise ValueError(f"Invalid Google token: {str(e)}")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ✅ Async Apple token verification
|
| 33 |
+
async def verify_apple_token(token: str, audience: str) -> Dict:
|
| 34 |
+
"""
|
| 35 |
+
Asynchronously verifies an Apple identity token and returns the decoded payload.
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
# Fetch Apple's public keys (JWKS)
|
| 39 |
+
async with httpx.AsyncClient() as client:
|
| 40 |
+
response = await client.get('https://appleid.apple.com/auth/keys')
|
| 41 |
+
response.raise_for_status()
|
| 42 |
+
keys = response.json().get('keys', [])
|
| 43 |
+
|
| 44 |
+
# Decode header to get kid and alg
|
| 45 |
+
header = jose_jwt.get_unverified_header(token)
|
| 46 |
+
kid = header.get('kid')
|
| 47 |
+
alg = header.get('alg')
|
| 48 |
+
|
| 49 |
+
key = next((k for k in keys if k['kid'] == kid and k['alg'] == alg), None)
|
| 50 |
+
if not key:
|
| 51 |
+
raise ValueError("Public key not found for Apple token")
|
| 52 |
+
|
| 53 |
+
public_key = jwk.construct(key)
|
| 54 |
+
message, encoded_sig = token.rsplit('.', 1)
|
| 55 |
+
decoded_sig = base64url_decode(encoded_sig.encode())
|
| 56 |
+
|
| 57 |
+
if not public_key.verify(message.encode(), decoded_sig):
|
| 58 |
+
raise ValueError("Invalid Apple token signature")
|
| 59 |
+
|
| 60 |
+
claims = jose_jwt.decode(
|
| 61 |
+
token,
|
| 62 |
+
key,
|
| 63 |
+
algorithms=['RS256'],
|
| 64 |
+
audience=audience,
|
| 65 |
+
issuer='https://appleid.apple.com'
|
| 66 |
+
)
|
| 67 |
+
return claims
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
raise ValueError(f"Invalid Apple token: {str(e)}")
|
requirements.txt
CHANGED
|
@@ -16,4 +16,6 @@ twilio
|
|
| 16 |
|
| 17 |
# Optional useful utilities (highly recommended)
|
| 18 |
httpx # async HTTP client (e.g., for calling 3rd party APIs)
|
| 19 |
-
passlib[bcrypt] # password hashing (if needed)
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
# Optional useful utilities (highly recommended)
|
| 18 |
httpx # async HTTP client (e.g., for calling 3rd party APIs)
|
| 19 |
+
passlib[bcrypt] # password hashing (if needed)
|
| 20 |
+
|
| 21 |
+
httpx
|