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:
+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
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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([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"
|
||||
)
|
||||
@@ -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(
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}%"),
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user