Merge pull request 'furqan' (#12) from furqan into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_backend/pulls/12
This commit is contained in:
furqan
2026-02-17 06:28:01 +00:00
26 changed files with 475 additions and 283 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -38,10 +38,11 @@ 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
COOKIE_SECURE=true
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
# External SaaS Webhook
+1 -1
View File
@@ -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
+74 -70
View File
@@ -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()
return app
+24 -6
View File
@@ -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(
+6 -2
View File
@@ -68,11 +68,15 @@ 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"
# Cookie settings
COOKIE_SECURE: bool = False
COOKIE_DOMAIN: Optional[str] = None
# Super Admin Setup
SUPER_ADMIN_EMAIL: str
SUPER_ADMIN_PASSWORD: str
+6 -2
View File
@@ -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
@@ -40,8 +44,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):
+20 -5
View File
@@ -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,
@@ -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(
+1 -1
View File
@@ -115,7 +115,7 @@ class SyncRedisClient:
if not self._redis:
try:
self.connect()
except:
except Exception:
pass
if not self._redis:
return 0
+2 -4
View File
@@ -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()
+4 -4
View File
@@ -1,4 +1,4 @@
from .auth import router
from .tenant import router
from .role import router
from .user import router
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
+47 -20
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, status
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,
@@ -14,13 +16,20 @@ 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
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,
@@ -30,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(
@@ -56,21 +83,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 +99,18 @@ 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,
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")
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)):
+5 -4
View File
@@ -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
@@ -67,10 +68,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)
+7 -1
View File
@@ -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
@@ -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"
+7 -6
View File
@@ -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
+170 -72
View File
@@ -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"}
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
+10 -16
View File
@@ -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
+3 -3
View File
@@ -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
+59 -47
View File
@@ -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",
+2 -2
View File
@@ -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"
+15
View File
@@ -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}%"),
+3 -7
View File
@@ -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})
+2 -1
View File
@@ -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
typer==0.21.1
httpx==0.28.1