From c3017b81daeedec46dfe815758ae2347f956dfa8 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Mon, 16 Feb 2026 15:32:05 +0530 Subject: [PATCH 1/2] fix: security fix --- .env.development | 2 +- .env.local | 2 +- .env.production | 2 +- .env.testing | 2 +- app/__init__.py | 144 +++++++------- app/config/security.py | 30 ++- app/config/settings.py | 2 +- app/controllers/auth/auth_controller.py | 4 +- app/core/redis.py | 2 +- app/middleware/auth_middleware.py | 6 +- app/routes/auth/auth.py | 29 ++- app/schemas/auth/auth_schema.py | 6 +- app/schemas/auth/language_schema.py | 8 +- app/services/auth/access_service.py | 13 +- app/services/auth/auth_service.py | 242 +++++++++++++++++------- app/services/auth/email_service.py | 26 +-- app/services/auth/event_service.py | 6 +- app/services/auth/sso_service.py | 106 ++++++----- requirements.txt | 3 +- 19 files changed, 382 insertions(+), 253 deletions(-) diff --git a/.env.development b/.env.development index e7d79c5..4d6b4ac 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.local b/.env.local index 18f9f83..a004628 100644 --- a/.env.local +++ b/.env.local @@ -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 diff --git a/.env.production b/.env.production index 6642417..86417ab 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/.env.testing b/.env.testing index 3ae3efc..fc0c90d 100644 --- a/.env.testing +++ b/.env.testing @@ -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 diff --git a/app/__init__.py b/app/__init__.py index 9d11048..3a90b55 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 === @@ -82,7 +150,7 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, allow_origins=origins, - allow_credentials=True, + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @@ -102,7 +170,7 @@ def create_app() -> FastAPI: app.include_router(access_router, prefix="/api/access", tags=["Access Management"]) app.include_router(user_router, prefix="/api/user", tags=["User Management"]) from app.routes.internal.module import router as internal_module_router - + app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"]) app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"]) app.include_router(module_router, prefix="/api/modules", tags=["Modules"]) @@ -115,74 +183,11 @@ def create_app() -> FastAPI: from app.routes.admin.modules import router as admin_modules_router from app.routes.admin.module_environments import router as admin_module_env_router from app.routes.admin.tenant_modules import router as admin_tenant_modules_router - + app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"]) 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", @@ -221,6 +227,4 @@ def create_app() -> FastAPI: "version": settings.VERSION, } - return app - -app = create_app() \ No newline at end of file + return app \ No newline at end of file diff --git a/app/config/security.py b/app/config/security.py index da0cbc5..894714e 100644 --- a/app/config/security.py +++ b/app/config/security.py @@ -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,24 +32,24 @@ 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: to_encode["tenant_id"] = str(tenant_id) - + return jwt.encode( to_encode, settings.ACCESS_TOKEN_SECRET, algorithm="HS256" ) - + @staticmethod def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str: """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: @@ -71,7 +76,20 @@ class SecurityUtils: status_code=status.HTTP_401_UNAUTHORIZED, 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( diff --git a/app/config/settings.py b/app/config/settings.py index 9be5507..5745ee2 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -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" diff --git a/app/controllers/auth/auth_controller.py b/app/controllers/auth/auth_controller.py index afce2af..0614f0c 100644 --- a/app/controllers/auth/auth_controller.py +++ b/app/controllers/auth/auth_controller.py @@ -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): diff --git a/app/core/redis.py b/app/core/redis.py index 60bb8fb..c632730 100644 --- a/app/core/redis.py +++ b/app/core/redis.py @@ -115,7 +115,7 @@ class SyncRedisClient: if not self._redis: try: self.connect() - except: + except Exception: pass if not self._redis: return 0 diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py index f355810..ab0e89e 100644 --- a/app/middleware/auth_middleware.py +++ b/app/middleware/auth_middleware.py @@ -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) @@ -73,10 +74,7 @@ def can_access(user: User, access_code: str, db: Session) -> bool: user_access_codes = {ra.access.access_code for ra in user.role.role_accesses} if access_code in user_access_codes: - return True - - from app.models.auth.access_models import Access - + return True requested_access = db.query(Access).filter( Access.access_code == access_code ).first() diff --git a/app/routes/auth/auth.py b/app/routes/auth/auth.py index 79a689e..a5b78c4 100644 --- a/app/routes/auth/auth.py +++ b/app/routes/auth/auth.py @@ -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)): diff --git a/app/schemas/auth/auth_schema.py b/app/schemas/auth/auth_schema.py index a4f2076..5b11624 100644 --- a/app/schemas/auth/auth_schema.py +++ b/app/schemas/auth/auth_schema.py @@ -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 - new_password: str = Field(..., min_length=8) + otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$') + new_password: str = Field(..., min_length=8) \ No newline at end of file diff --git a/app/schemas/auth/language_schema.py b/app/schemas/auth/language_schema.py index bbfab70..5295cca 100644 --- a/app/schemas/auth/language_schema.py +++ b/app/schemas/auth/language_schema.py @@ -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 \ No newline at end of file + preferred_language: LanguageEnum \ No newline at end of file diff --git a/app/services/auth/access_service.py b/app/services/auth/access_service.py index 57f5043..9a6a8aa 100644 --- a/app/services/auth/access_service.py +++ b/app/services/auth/access_service.py @@ -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 diff --git a/app/services/auth/auth_service.py b/app/services/auth/auth_service.py index 8a83ec5..70cd9c8 100644 --- a/app/services/auth/auth_service.py +++ b/app/services/auth/auth_service.py @@ -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([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}) + otp_code = "".join([secrets.choice("0123456789") for _ in range(6)]) + + 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" - ) - - 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"} \ No newline at end of file + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to reset password" + ) \ No newline at end of file diff --git a/app/services/auth/email_service.py b/app/services/auth/email_service.py index 4ab2026..ab1c984 100644 --- a/app/services/auth/email_service.py +++ b/app/services/auth/email_service.py @@ -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 \ No newline at end of file diff --git a/app/services/auth/event_service.py b/app/services/auth/event_service.py index 910dcb1..505a9f7 100644 --- a/app/services/auth/event_service.py +++ b/app/services/auth/event_service.py @@ -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 diff --git a/app/services/auth/sso_service.py b/app/services/auth/sso_service.py index 69f2bdf..26b9f9c 100644 --- a/app/services/auth/sso_service.py +++ b/app/services/auth/sso_service.py @@ -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( @@ -61,25 +65,27 @@ class SSOService: raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.") 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,58 +182,64 @@ 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 grant.is_used: - raise HTTPException(status_code=401, detail="Grant code already used") - - if grant.expires_at < datetime.now(timezone.utc): - raise HTTPException(status_code=401, detail="Grant code expired") - + if not sync_redis_client.client: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="SSO service temporarily unavailable" + ) + + redis_key = f"sso_grant:{grant_code}" + + 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 grant.environment_slug != environment_slug: + if not module or str(module.id) != grant_data["module_id"]: + raise HTTPException(status_code=401, detail="Grant invalid for this module") + + 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: if user.role.role_module_accesses: for rma in user.role.role_module_accesses: if rma.module_access and rma.module_access.module_id == module.id: permissions.append(rma.module_access.access_code) - + 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, "roles": [user.role.role_name] if user.role else [] } - + token = security.generate_module_token(token_payload, module_id) - + return { "access_token": token, "token_type": "bearer", diff --git a/requirements.txt b/requirements.txt index 687aaa9..4cfa27f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pyjwt>=2.8.0 email-validator>=2.1.0 redis==7.1.0 requests==2.32.5 -typer==0.21.1 \ No newline at end of file +typer==0.21.1 +httpx==0.28.1 \ No newline at end of file From 512c39e4f4325103dfb812d7704dfbfb5a97a3e1 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Tue, 17 Feb 2026 11:57:24 +0530 Subject: [PATCH 2/2] fix: stronger handling of data --- .env.production | 1 + app/config/settings.py | 6 ++- app/controllers/auth/auth_controller.py | 4 ++ app/controllers/auth/sso_controller.py | 25 ++++++++--- .../theme/color_palette_controller.py | 8 ++-- app/routes/auth/__init__.py | 8 ++-- app/routes/auth/auth.py | 42 ++++++++++++++++--- app/schemas/auth/auth_schema.py | 3 +- app/schemas/auth/module_environment_schema.py | 1 - app/services/auth/trust_service.py | 4 +- app/services/auth/user_service.py | 15 +++++++ app/services/theme/color_palette_service.py | 10 ++--- 12 files changed, 95 insertions(+), 32 deletions(-) diff --git a/.env.production b/.env.production index 86417ab..2e2e9fe 100644 --- a/.env.production +++ b/.env.production @@ -42,6 +42,7 @@ ACCESS_TOKEN_EXPIRES=900 REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ" REFRESH_TOKEN_EXPIRES=864000 JWT_ALGORITHM=HS256 +COOKIE_SECURE=true ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA" # External SaaS Webhook diff --git a/app/config/settings.py b/app/config/settings.py index 5745ee2..b062f5a 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -72,7 +72,11 @@ class Settings(BaseSettings): REFRESH_TOKEN_SECRET: str REFRESH_TOKEN_EXPIRES: int = 864000 JWT_ALGORITHM: str = "HS256" - + + # Cookie settings + COOKIE_SECURE: bool = False + COOKIE_DOMAIN: Optional[str] = None + # Super Admin Setup SUPER_ADMIN_EMAIL: str SUPER_ADMIN_PASSWORD: str diff --git a/app/controllers/auth/auth_controller.py b/app/controllers/auth/auth_controller.py index 0614f0c..9c9b568 100644 --- a/app/controllers/auth/auth_controller.py +++ b/app/controllers/auth/auth_controller.py @@ -27,6 +27,10 @@ class AuthController: def refresh_token(db: Session, token_data: RefreshTokenRequest): return AuthService.refresh_access_token(db, token_data.refresh_token) + @staticmethod + def refresh_token_raw(db: Session, refresh_token: str): + return AuthService.refresh_access_token(db, refresh_token) + @staticmethod def update_user( db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User diff --git a/app/controllers/auth/sso_controller.py b/app/controllers/auth/sso_controller.py index 80f21b1..11690fa 100644 --- a/app/controllers/auth/sso_controller.py +++ b/app/controllers/auth/sso_controller.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Session from fastapi import Request, HTTPException from typing import Optional, Dict, Any import uuid +import logging from app.services.auth.sso_service import SSOService from app.services.auth.trust_service import TrustService from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest @@ -9,6 +10,8 @@ from app.models.auth.module_model import Module from app.models.auth.module_environment_model import ModuleEnvironment from app.models.auth.user_model import User +logger = logging.getLogger(__name__) + class SSOController: @staticmethod def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User): @@ -44,11 +47,23 @@ class SSOController: if x_module_key: headers["X-Module-Key"] = x_module_key - TrustService.validate_module_trust( - environment=env, - request_headers=headers, - request_body="" - ) + actual_body = payload.model_dump_json() + try: + TrustService.validate_module_trust( + environment=env, + request_headers=headers, + request_body=actual_body + ) + except HTTPException: + logger.warning( + "HMAC verify with body failed for %s, trying empty fallback (DEPRECATED)", + payload.module_id, + ) + TrustService.validate_module_trust( + environment=env, + request_headers=headers, + request_body="" + ) return SSOService.exchange_grant( db=db, diff --git a/app/controllers/theme/color_palette_controller.py b/app/controllers/theme/color_palette_controller.py index c8c56b5..43ddebd 100644 --- a/app/controllers/theme/color_palette_controller.py +++ b/app/controllers/theme/color_palette_controller.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from uuid import UUID -from typing import List, Optional +from typing import List from app.models.auth.user_model import User from app.models.theme.color_palette_model import ColorPalette from app.schemas.theme.color_palette_schema import ( @@ -12,9 +12,7 @@ from app.services.theme.color_palette_service import PaletteService class PaletteController: @staticmethod def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]: - - tenant_id = current_user.tenant_id - return PaletteService.get_all_palettes(db, tenant_id) + return PaletteService.get_all_palettes(db) @staticmethod def get_palette(db: Session, palette_id: UUID) -> ColorPalette: @@ -24,7 +22,7 @@ class PaletteController: def create_palette( db: Session, data: ColorPaletteCreate, current_user: User ) -> ColorPalette: - return PaletteService.create_palette(db, data, tenant_id=None) + return PaletteService.create_palette(db, data) @staticmethod def update_palette( diff --git a/app/routes/auth/__init__.py b/app/routes/auth/__init__.py index 693ec4f..c81e46b 100644 --- a/app/routes/auth/__init__.py +++ b/app/routes/auth/__init__.py @@ -1,4 +1,4 @@ -from .auth import router -from .tenant import router -from .role import router -from .user import router \ No newline at end of file +from .auth import router as auth_router +from .tenant import router as tenant_router +from .role import router as role_router +from .user import router as user_router \ No newline at end of file diff --git a/app/routes/auth/auth.py b/app/routes/auth/auth.py index a5b78c4..6259596 100644 --- a/app/routes/auth/auth.py +++ b/app/routes/auth/auth.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, Depends, Request, status, HTTPException +from fastapi import APIRouter, Depends, Request, Response, status, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session from app.config.database import get_db +from app.config.settings import settings from app.controllers.auth.auth_controller import AuthController from app.schemas.auth.auth_schema import ( UserSignup, @@ -23,6 +24,12 @@ import uuid router = APIRouter() +def _cookie_kwargs(): + kwargs = dict(httponly=True, samesite="lax", secure=settings.COOKIE_SECURE, path="/") + if settings.COOKIE_DOMAIN: + kwargs["domain"] = settings.COOKIE_DOMAIN + return kwargs + @router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def signup( user_data: UserSignup, @@ -32,12 +39,30 @@ def signup( return AuthController.signup(db, user_data, tenant_id) @router.post("/signin", response_model=TokenResponse) -def signin(signin_data: UserSignin, db: Session = Depends(get_db)): - return AuthController.signin(db, signin_data) +def signin(signin_data: UserSignin, response: Response, db: Session = Depends(get_db)): + result = AuthController.signin(db, signin_data) + cookie_kw = _cookie_kwargs() + access_max_age = settings.ACCESS_TOKEN_EXPIRES if signin_data.remember_me else None + refresh_max_age = settings.REFRESH_TOKEN_EXPIRES if signin_data.remember_me else None + response.set_cookie(key="access_token", value=result["access_token"], max_age=access_max_age, **cookie_kw) + response.set_cookie(key="refresh_token", value=result["refresh_token"], max_age=refresh_max_age, **cookie_kw) + return result @router.post("/refresh", response_model=TokenResponse) -def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)): - return AuthController.refresh_token(db, token_data) +def refresh_token( + token_data: RefreshTokenRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +): + refresh_tok = token_data.refresh_token or request.cookies.get("refresh_token") + if not refresh_tok: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token") + result = AuthController.refresh_token_raw(db, refresh_tok) + cookie_kw = _cookie_kwargs() + response.set_cookie(key="access_token", value=result["access_token"], **cookie_kw) + response.set_cookie(key="refresh_token", value=result["refresh_token"], **cookie_kw) + return result @router.get("/me", response_model=UserResponse) def get_me( @@ -76,11 +101,16 @@ def reset_password( @router.post("/logout") def logout( request: Request, + response: Response, 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) + result = AuthController.logout(current_user, token) + cookie_kw = _cookie_kwargs() + response.delete_cookie(key="access_token", **cookie_kw) + response.delete_cookie(key="refresh_token", **cookie_kw) + return result @router.post("/forgot-password") def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)): diff --git a/app/schemas/auth/auth_schema.py b/app/schemas/auth/auth_schema.py index 5b11624..6a9d12e 100644 --- a/app/schemas/auth/auth_schema.py +++ b/app/schemas/auth/auth_schema.py @@ -16,6 +16,7 @@ class UserSignup(UserBase): class UserSignin(BaseModel): email: EmailStr password: str + remember_me: bool = False class AccessInRole(BaseModel): id: str @@ -55,7 +56,7 @@ class TokenResponse(BaseModel): class RefreshTokenRequest(BaseModel): - refresh_token: str + refresh_token: Optional[str] = None class ResetPassword(BaseModel): old_password: str diff --git a/app/schemas/auth/module_environment_schema.py b/app/schemas/auth/module_environment_schema.py index 73f5503..ee99eeb 100644 --- a/app/schemas/auth/module_environment_schema.py +++ b/app/schemas/auth/module_environment_schema.py @@ -37,7 +37,6 @@ class EnvironmentResponse(BaseModel): frontend_base_url: str backend_base_url: str sso_entry_path: str - sso_entry_path: str permission_sync_endpoint: str sso_exchange_endpoint: Optional[str] provisioning_endpoint: Optional[str] = "/internal/tenants/provision" diff --git a/app/services/auth/trust_service.py b/app/services/auth/trust_service.py index b6fa1f4..61219f7 100644 --- a/app/services/auth/trust_service.py +++ b/app/services/auth/trust_service.py @@ -14,7 +14,7 @@ class TrustService: if environment.trust_type != "hmac": if environment.trust_type == "static_key": secret = environment.trust_credentials.get("secret_key") - return signature == secret + return hmac.compare_digest(signature, secret) return False secret = environment.trust_credentials.get("hmac_secret") @@ -101,7 +101,7 @@ class TrustService: elif environment.trust_type == "static_key": api_key = request_headers.get("X-Module-Key") secret = environment.trust_credentials.get("secret_key") - if not api_key or api_key != secret: + if not api_key or not hmac.compare_digest(api_key, secret): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key" diff --git a/app/services/auth/user_service.py b/app/services/auth/user_service.py index 4dd68be..a5624b6 100644 --- a/app/services/auth/user_service.py +++ b/app/services/auth/user_service.py @@ -13,6 +13,7 @@ import json from app.models.auth.role_module_access_model import RoleModuleAccess from app.models.auth.module_access_model import ModuleAccess from app.models.auth.tenant_module_model import TenantModule +from app.models.auth.role_model import Role logger = logging.getLogger(__name__) @@ -108,6 +109,19 @@ class UserService: if tenant_id: update_dict.pop("tenant_id", None) + if "role_id" in update_dict and update_dict["role_id"] is not None: + role = db.query(Role).filter(Role.id == update_dict["role_id"]).first() + if not role: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Role not found" + ) + if tenant_id and role.tenant_id is not None and role.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot assign a role from another tenant" + ) + if "email" in update_dict and update_dict["email"] != user.email: if db.query(User).filter(User.email == update_dict["email"]).first(): raise HTTPException( @@ -231,6 +245,7 @@ class UserService: if search and search.strip(): search_term = search.strip() + search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") query = query.filter( or_( User.email.ilike(f"%{search_term}%"), diff --git a/app/services/theme/color_palette_service.py b/app/services/theme/color_palette_service.py index 5b88bd7..5a7daab 100644 --- a/app/services/theme/color_palette_service.py +++ b/app/services/theme/color_palette_service.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from uuid import UUID -from typing import List, Optional +from typing import List from fastapi import HTTPException, status from app.models.theme.color_palette_model import ColorPalette from app.schemas.theme.color_palette_schema import ( @@ -10,9 +10,7 @@ from app.schemas.theme.color_palette_schema import ( class PaletteService: @staticmethod - def get_all_palettes( - db: Session, tenant_id: Optional[UUID] = None - ) -> List[ColorPalette]: + def get_all_palettes(db: Session) -> List[ColorPalette]: return db.query(ColorPalette).all() @staticmethod @@ -23,9 +21,7 @@ class PaletteService: return palette @staticmethod - def create_palette( - db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None - ) -> ColorPalette: + def create_palette(db: Session, data: ColorPaletteCreate) -> ColorPalette: if data.is_default: db.query(ColorPalette).update({"is_default": False})