fix: security fix
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
+70
-66
@@ -1,3 +1,4 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
@@ -31,6 +32,72 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Testing database connection...")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.commit()
|
||||
logger.info("Database connection successful!")
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection failed: {e}")
|
||||
raise
|
||||
|
||||
await redis_client.connect()
|
||||
await run_in_threadpool(sync_redis_client.connect)
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
async def redis_event_consumer():
|
||||
logger.info("Redis Event Consumer STARTED")
|
||||
while True:
|
||||
try:
|
||||
if not redis_client.client:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||
|
||||
if result:
|
||||
_, event_id = result
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def fallback_poller():
|
||||
logger.info("Fallback Event Poller STARTED")
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_outbox, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback poller error: {e}")
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
background_tasks = [
|
||||
asyncio.create_task(redis_event_consumer()),
|
||||
asyncio.create_task(fallback_poller()),
|
||||
]
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down...")
|
||||
for task in background_tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
await redis_client.close()
|
||||
await run_in_threadpool(sync_redis_client.close)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
@@ -39,6 +106,7 @@ def create_app() -> FastAPI:
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === OpenAPI Security Scheme ===
|
||||
@@ -120,69 +188,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
||||
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||
|
||||
|
||||
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Testing database connection...")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.commit()
|
||||
logger.info("Database connection successful!")
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection failed: {e}")
|
||||
raise
|
||||
|
||||
from app.core.redis import redis_client
|
||||
await redis_client.connect()
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
|
||||
async def redis_event_consumer():
|
||||
logger.info("Redis Event Consumer STARTED")
|
||||
while True:
|
||||
try:
|
||||
if not redis_client.client:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||
|
||||
if result:
|
||||
_, event_id = result
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def fallback_poller():
|
||||
logger.info("Fallback Event Poller STARTED")
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_outbox, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback poller error: {e}")
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
asyncio.create_task(redis_event_consumer())
|
||||
asyncio.create_task(fallback_poller())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Shutting down...")
|
||||
await redis_client.close()
|
||||
|
||||
|
||||
# === Basic Routes ===
|
||||
@app.get("/", tags=["Root"])
|
||||
def root():
|
||||
@@ -212,7 +217,8 @@ def create_app() -> FastAPI:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
logger.error(f"Health check DB error: {e}")
|
||||
db_status = "unhealthy"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
@@ -222,5 +228,3 @@ def create_app() -> FastAPI:
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
+20
-2
@@ -2,11 +2,16 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import bcrypt
|
||||
import jwt
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import HTTPException, status
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from app.config.settings import settings
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityUtils:
|
||||
"""Security utility class for authentication and authorization."""
|
||||
@@ -27,7 +32,7 @@ class SecurityUtils:
|
||||
"""Generate JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
@@ -44,7 +49,7 @@ class SecurityUtils:
|
||||
"""Generate JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
@@ -72,6 +77,19 @@ class SecurityUtils:
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and sync_redis_client.client:
|
||||
try:
|
||||
if sync_redis_client.client.get(f"blacklist:{jti}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis blacklist check failed: {e}")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# JWT settings
|
||||
ACCESS_TOKEN_SECRET: str
|
||||
ACCESS_TOKEN_EXPIRES: int = 86400
|
||||
ACCESS_TOKEN_EXPIRES: int = 900
|
||||
REFRESH_TOKEN_SECRET: str
|
||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
@@ -40,8 +40,8 @@ class AuthController:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
return AuthService.logout(current_user)
|
||||
def logout(current_user: User, token: str):
|
||||
return AuthService.logout(current_user, token)
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ class SyncRedisClient:
|
||||
if not self._redis:
|
||||
try:
|
||||
self.connect()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
if not self._redis:
|
||||
return 0
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import List
|
||||
from app.config.database import get_db
|
||||
from app.config.security import security
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.access_model import Access
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -74,9 +75,6 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||
|
||||
if access_code in user_access_codes:
|
||||
return True
|
||||
|
||||
from app.models.auth.access_models import Access
|
||||
|
||||
requested_access = db.query(Access).filter(
|
||||
Access.access_code == access_code
|
||||
).first()
|
||||
|
||||
+13
-16
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Request, status, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.auth_controller import AuthController
|
||||
@@ -14,7 +15,8 @@ from app.schemas.auth.auth_schema import (
|
||||
VerifyOTPRequest,
|
||||
ResetPasswordWithOTP,
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user
|
||||
from app.schemas.auth.language_schema import UpdateLanguageRequest
|
||||
from app.middleware.auth_middleware import get_current_user, security_scheme
|
||||
from app.middleware.tenant_middleware import get_tenant_from_header
|
||||
from app.models.auth.user_model import User
|
||||
import uuid
|
||||
@@ -56,21 +58,11 @@ def update_user(
|
||||
@router.patch("/update/{user_id}/language", response_model=UserResponse)
|
||||
def update_language(
|
||||
user_id: uuid.UUID,
|
||||
language_data: dict,
|
||||
language_data: UpdateLanguageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
supported_languages = ["en", "ar"]
|
||||
preferred_language = language_data.get("preferred_language")
|
||||
|
||||
if preferred_language not in supported_languages:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported language. Supported languages: {', '.join(supported_languages)}"
|
||||
)
|
||||
|
||||
user_update = UserUpdate(preferred_language=preferred_language)
|
||||
user_update = UserUpdate(preferred_language=language_data.preferred_language.value)
|
||||
return AuthController.update_user(db, user_id, user_update, current_user)
|
||||
|
||||
@router.post("/reset-password")
|
||||
@@ -82,8 +74,13 @@ def reset_password(
|
||||
return AuthController.reset_password(db, current_user, password_data)
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(current_user: User = Depends(get_current_user)):
|
||||
return AuthController.logout(current_user)
|
||||
def logout(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||
return AuthController.logout(current_user, token)
|
||||
|
||||
@router.post("/forgot-password")
|
||||
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -67,10 +67,10 @@ class ForgotPasswordRequest(BaseModel):
|
||||
|
||||
class VerifyOTPRequest(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
|
||||
|
||||
|
||||
class ResetPasswordWithOTP(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
|
||||
new_password: str = Field(..., min_length=8)
|
||||
@@ -1,4 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from enum import Enum
|
||||
|
||||
class LanguageEnum(str, Enum):
|
||||
"""Supported languages for the application."""
|
||||
EN = "en"
|
||||
AR = "ar"
|
||||
|
||||
class UpdateLanguageRequest(BaseModel):
|
||||
preferred_language: str
|
||||
preferred_language: LanguageEnum
|
||||
@@ -1,13 +1,15 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.models.auth.access_model import Access
|
||||
from typing import List, Any
|
||||
from app.core.redis import sync_redis_client
|
||||
import json
|
||||
import logging
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.module_model import Module
|
||||
from sqlalchemy.orm import joinedload
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AccessService:
|
||||
|
||||
@staticmethod
|
||||
@@ -35,7 +37,7 @@ class AccessService:
|
||||
|
||||
return deserialized_list
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning(f"Access cache read error: {e}")
|
||||
|
||||
query = db.query(Access)
|
||||
|
||||
@@ -73,10 +75,9 @@ class AccessService:
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
})
|
||||
|
||||
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600) # 1 hour cache
|
||||
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
logger.warning(f"Access cache write error: {e}")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -3,10 +3,16 @@ from fastapi import HTTPException, status
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
|
||||
from app.config.security import security
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
import uuid
|
||||
import logging
|
||||
import jwt
|
||||
from app.config.settings import settings
|
||||
from app.services.auth.email_service import EmailService
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthService:
|
||||
|
||||
@@ -159,7 +165,17 @@ class AuthService:
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
def logout(current_user: User, token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp and sync_redis_client.client:
|
||||
remaining_ttl = int(exp - datetime.now(timezone.utc).timestamp())
|
||||
if remaining_ttl > 0:
|
||||
sync_redis_client.client.setex(f"blacklist:{jti}", remaining_ttl, "1")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to blacklist token on logout: {e}")
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
@staticmethod
|
||||
@@ -196,92 +212,174 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def forgot_password(db: Session, email: str):
|
||||
print(f"DEBUG: Processing forgot_password for email: {email}")
|
||||
"""Generate and send OTP for password reset via Redis storage."""
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
print(f"DEBUG: User not found for email: {email}")
|
||||
return {"message": "If the email is registered, an OTP has been sent."}
|
||||
|
||||
print(f"DEBUG: User found: {user.id}")
|
||||
otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
|
||||
|
||||
otp_code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
expires_at = datetime.utcnow() + timedelta(minutes=10)
|
||||
print(f"DEBUG: Generated OTP: {otp_code}, Expires: {expires_at}")
|
||||
|
||||
db.query(PasswordResetOTP).filter(
|
||||
PasswordResetOTP.email == email, PasswordResetOTP.is_used == False
|
||||
).update({"is_used": True})
|
||||
redis_key = f"otp:{email}"
|
||||
try:
|
||||
if sync_redis_client.client:
|
||||
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
|
||||
logger.info(f"OTP generated for email: {email}")
|
||||
else:
|
||||
logger.error("Redis client unavailable for OTP storage")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password reset service temporarily unavailable"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error storing OTP: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate OTP"
|
||||
)
|
||||
|
||||
try:
|
||||
otp_entry = PasswordResetOTP(
|
||||
email=email, otp=otp_code, expires_at=expires_at
|
||||
)
|
||||
db.add(otp_entry)
|
||||
db.commit()
|
||||
print("DEBUG: OTP stored in database successfully")
|
||||
EmailService.send_otp(email, otp_code)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Database error saving OTP: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
# Send Email
|
||||
print("DEBUG: Attempting to send email...")
|
||||
email_sent = EmailService.send_otp(email, otp_code)
|
||||
print(f"DEBUG: Email sending result: {email_sent}")
|
||||
logger.error(f"Failed to send OTP email to {email}: {e}")
|
||||
|
||||
return {"message": "If the email is registered, an OTP has been sent."}
|
||||
|
||||
@staticmethod
|
||||
def _check_otp_attempts(email: str):
|
||||
"""Check if OTP attempts are exceeded. Raises 429 if locked out."""
|
||||
attempts_key = f"otp_attempts:{email}"
|
||||
try:
|
||||
attempts = sync_redis_client.client.get(attempts_key)
|
||||
if attempts and int(attempts) >= 5:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many attempts. Try again in 15 minutes."
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis error checking OTP attempts: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _increment_otp_attempts(email: str):
|
||||
"""Increment failed OTP attempt counter with 15-minute TTL."""
|
||||
attempts_key = f"otp_attempts:{email}"
|
||||
try:
|
||||
pipe = sync_redis_client.client.pipeline()
|
||||
pipe.incr(attempts_key)
|
||||
pipe.expire(attempts_key, 900)
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis error incrementing OTP attempts: {e}")
|
||||
|
||||
@staticmethod
|
||||
def verify_otp(db: Session, email: str, otp: str):
|
||||
otp_entry = (
|
||||
db.query(PasswordResetOTP)
|
||||
.filter(
|
||||
PasswordResetOTP.email == email,
|
||||
PasswordResetOTP.otp == otp,
|
||||
PasswordResetOTP.is_used == False,
|
||||
PasswordResetOTP.expires_at > datetime.utcnow(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
"""Verify OTP from Redis storage using constant-time comparison."""
|
||||
redis_key = f"otp:{email}"
|
||||
|
||||
if not otp_entry:
|
||||
try:
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Verification service temporarily unavailable"
|
||||
)
|
||||
|
||||
AuthService._check_otp_attempts(email)
|
||||
|
||||
stored_otp = sync_redis_client.client.get(redis_key)
|
||||
|
||||
if not stored_otp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(otp, stored_otp):
|
||||
AuthService._increment_otp_attempts(email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
sync_redis_client.client.delete(redis_key)
|
||||
sync_redis_client.client.delete(f"otp_attempts:{email}")
|
||||
sync_redis_client.client.setex(f"otp_verified:{email}", 300, "1")
|
||||
|
||||
logger.info(f"OTP verified successfully for email: {email}")
|
||||
return {"message": "OTP verified successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error verifying OTP: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to verify OTP"
|
||||
)
|
||||
|
||||
return {"message": "OTP verified successfully"}
|
||||
|
||||
@staticmethod
|
||||
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
|
||||
otp_entry = (
|
||||
db.query(PasswordResetOTP)
|
||||
.filter(
|
||||
PasswordResetOTP.email == email,
|
||||
PasswordResetOTP.otp == otp,
|
||||
PasswordResetOTP.is_used == False,
|
||||
PasswordResetOTP.expires_at > datetime.utcnow(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
"""Reset password after verifying OTP and delete OTP from Redis."""
|
||||
redis_key = f"otp:{email}"
|
||||
verified_key = f"otp_verified:{email}"
|
||||
|
||||
if not otp_entry:
|
||||
try:
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password reset service temporarily unavailable"
|
||||
)
|
||||
|
||||
AuthService._check_otp_attempts(email)
|
||||
|
||||
is_pre_verified = sync_redis_client.client.get(verified_key)
|
||||
|
||||
if not is_pre_verified:
|
||||
stored_otp = sync_redis_client.client.get(redis_key)
|
||||
|
||||
if not stored_otp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(otp, stored_otp):
|
||||
AuthService._increment_otp_attempts(email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not security.validate_password_strength(new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password too weak"
|
||||
)
|
||||
|
||||
user.password = security.hash_password(new_password)
|
||||
user.password_updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
sync_redis_client.client.delete(redis_key)
|
||||
sync_redis_client.client.delete(verified_key)
|
||||
sync_redis_client.client.delete(f"otp_attempts:{email}")
|
||||
logger.info(f"Password reset successfully for email: {email}")
|
||||
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting password: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to reset password"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
if not security.validate_password_strength(new_password):
|
||||
raise HTTPException(status_code=400, detail="Password too weak")
|
||||
|
||||
user.password = security.hash_password(new_password)
|
||||
user.password_updated_at = datetime.utcnow()
|
||||
|
||||
otp_entry.is_used = True
|
||||
|
||||
db.commit()
|
||||
return {"message": "Password updated successfully"}
|
||||
@@ -1,41 +1,35 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
from app.config.settings import settings
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EmailService:
|
||||
SMTP_HOST = os.getenv("SMTP_HOST")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
|
||||
SMTP_USER = os.getenv("SMTP_USER")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
|
||||
EMAIL_FROM = os.getenv("EMAIL_FROM")
|
||||
SMTP_SECURE = os.getenv("SMTP_SECURE", "true").lower() == "true"
|
||||
|
||||
@staticmethod
|
||||
def send_otp(to_email: str, otp: str):
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = EmailService.EMAIL_FROM
|
||||
msg['From'] = settings.EMAIL_FROM
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = "Password Reset OTP"
|
||||
|
||||
body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes."
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
if EmailService.SMTP_SECURE:
|
||||
server = smtplib.SMTP_SSL(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
|
||||
if settings.SMTP_SECURE:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD)
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
text = msg.as_string()
|
||||
server.sendmail(EmailService.EMAIL_FROM, to_email, text)
|
||||
server.sendmail(settings.EMAIL_FROM, to_email, text)
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send email: {e}")
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
return False
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
import requests
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -163,7 +163,7 @@ class EventService:
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
response = httpx.post(log.target_url, content=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
@@ -233,7 +233,7 @@ class EventService:
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
|
||||
response = requests.post(log.target_url, data=payload_json, headers=headers, timeout=5)
|
||||
response = httpx.post(log.target_url, content=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,9 +11,12 @@ from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.user_model import User
|
||||
from app.config.security import security
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.core.redis import sync_redis_client
|
||||
import json
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SSOService:
|
||||
@staticmethod
|
||||
def generate_grant(
|
||||
@@ -62,24 +66,26 @@ class SSOService:
|
||||
|
||||
grant_code = str(uuid.uuid4().hex)
|
||||
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||
|
||||
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
|
||||
|
||||
grant = SSOGrant(
|
||||
grant_code=grant_code,
|
||||
user_id=user_id,
|
||||
module_id=module.id,
|
||||
tenant_id=tenant_id,
|
||||
environment_slug=env.slug,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(grant)
|
||||
db.commit()
|
||||
db.refresh(grant)
|
||||
grant_data = json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"module_id": str(module.id),
|
||||
"tenant_id": str(tenant_id) if tenant_id else None,
|
||||
"environment_slug": env.slug,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="SSO service temporarily unavailable"
|
||||
)
|
||||
|
||||
sync_redis_client.client.setex(f"sso_grant:{grant_code}", 60, grant_data)
|
||||
|
||||
return {
|
||||
"grant_code": grant.grant_code,
|
||||
"grant_code": grant_code,
|
||||
"redirect_url": redirect_url
|
||||
}
|
||||
|
||||
@@ -176,38 +182,44 @@ class SSOService:
|
||||
"""
|
||||
Validates grant and returns a short-lived module-scoped token.
|
||||
This is called by the Module Backend.
|
||||
Grants are stored in Redis — atomically deleted on exchange (one-time use).
|
||||
"""
|
||||
grant = db.query(SSOGrant).filter(SSOGrant.grant_code == grant_code).first()
|
||||
if not grant:
|
||||
raise HTTPException(status_code=401, detail="Invalid grant code")
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="SSO service temporarily unavailable"
|
||||
)
|
||||
|
||||
if grant.is_used:
|
||||
raise HTTPException(status_code=401, detail="Grant code already used")
|
||||
redis_key = f"sso_grant:{grant_code}"
|
||||
|
||||
if grant.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=401, detail="Grant code expired")
|
||||
pipe = sync_redis_client.client.pipeline()
|
||||
pipe.get(redis_key)
|
||||
pipe.delete(redis_key)
|
||||
grant_json, _ = pipe.execute()
|
||||
|
||||
if not grant_json:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired grant code")
|
||||
|
||||
grant_data = json.loads(grant_json)
|
||||
|
||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||
if not module or module.id != grant.module_id:
|
||||
raise HTTPException(status_code=401, detail="Grant invalid for this module")
|
||||
if not module or str(module.id) != grant_data["module_id"]:
|
||||
raise HTTPException(status_code=401, detail="Grant invalid for this module")
|
||||
|
||||
if grant.environment_slug != environment_slug:
|
||||
if grant_data["environment_slug"] != environment_slug:
|
||||
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
|
||||
|
||||
user = db.query(User).filter(User.id == grant.user_id).first()
|
||||
user = db.query(User).filter(User.id == grant_data["user_id"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
if grant.tenant_id:
|
||||
if user.tenant_id != grant.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Tenant mismatch for SSO grant"
|
||||
)
|
||||
|
||||
grant.is_used = True
|
||||
grant.used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
grant_tenant_id = grant_data.get("tenant_id")
|
||||
if grant_tenant_id:
|
||||
if str(user.tenant_id) != grant_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Tenant mismatch for SSO grant"
|
||||
)
|
||||
|
||||
permissions = []
|
||||
if user.role:
|
||||
@@ -219,7 +231,7 @@ class SSOService:
|
||||
token_payload = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": str(grant.tenant_id) if grant.tenant_id else None,
|
||||
"tenant_id": grant_tenant_id,
|
||||
"module_id": module_id,
|
||||
"environment": environment_slug,
|
||||
"permissions": permissions,
|
||||
|
||||
@@ -11,3 +11,4 @@ email-validator>=2.1.0
|
||||
redis==7.1.0
|
||||
requests==2.32.5
|
||||
typer==0.21.1
|
||||
httpx==0.28.1
|
||||
Reference in New Issue
Block a user