253 lines
9.4 KiB
Python
253 lines
9.4 KiB
Python
import uuid
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, Any, Optional
|
|
from sqlalchemy.orm import Session
|
|
from fastapi import HTTPException, status
|
|
from app.models.auth.sso_grant_model import SSOGrant
|
|
from app.models.auth.module_model import Module
|
|
from app.models.auth.module_environment_model import ModuleEnvironment
|
|
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(
|
|
db: Session,
|
|
user_id: uuid.UUID,
|
|
module_id: str,
|
|
tenant_id: Optional[uuid.UUID] = None
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Generates a one-time SSO grant code for the specified module.
|
|
Resolves the correct environment URL based on tenant/user config.
|
|
"""
|
|
module = db.query(Module).filter(Module.module_id == module_id).first()
|
|
if not module:
|
|
raise HTTPException(status_code=404, detail="Module not found")
|
|
|
|
if module.status != "active":
|
|
raise HTTPException(status_code=403, detail="Module is disabled")
|
|
|
|
environment_slug = "prod"
|
|
|
|
if tenant_id:
|
|
tm = db.query(TenantModule).filter(
|
|
TenantModule.tenant_id == tenant_id,
|
|
TenantModule.module_id == module.id
|
|
).first()
|
|
|
|
if not tm or not tm.is_active:
|
|
raise HTTPException(status_code=403, detail="Tenant does not have access to this module")
|
|
|
|
if tm.assigned_environment_slug:
|
|
environment_slug = tm.assigned_environment_slug
|
|
|
|
env = db.query(ModuleEnvironment).filter(
|
|
ModuleEnvironment.module_id == module.id,
|
|
ModuleEnvironment.slug == environment_slug
|
|
).first()
|
|
|
|
if not env:
|
|
env = db.query(ModuleEnvironment).filter(
|
|
ModuleEnvironment.module_id == module.id,
|
|
ModuleEnvironment.is_default == True
|
|
).first()
|
|
|
|
if not env:
|
|
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)
|
|
|
|
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
|
|
|
|
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_code,
|
|
"redirect_url": redirect_url
|
|
}
|
|
|
|
@staticmethod
|
|
def generate_signed_payload(
|
|
db: Session,
|
|
user_id: uuid.UUID,
|
|
module_id: str,
|
|
tenant_id: Optional[uuid.UUID] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Generates a signed payload for the client to POST directly to the module backend.
|
|
"""
|
|
module = db.query(Module).filter(Module.module_id == module_id).first()
|
|
if not module or module.status != "active":
|
|
raise HTTPException(status_code=404, detail="Module not found or disabled")
|
|
|
|
environment_slug = "prod"
|
|
if tenant_id:
|
|
tm = db.query(TenantModule).filter(TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id).first()
|
|
if tm and tm.is_active and tm.assigned_environment_slug:
|
|
environment_slug = tm.assigned_environment_slug
|
|
|
|
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first()
|
|
if not env:
|
|
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first()
|
|
|
|
if not env:
|
|
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
|
|
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
permissions = []
|
|
if user.role:
|
|
for ra in user.role.role_accesses:
|
|
if ra.access:
|
|
pass
|
|
|
|
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)
|
|
|
|
|
|
timestamp = int(time.time() * 1000)
|
|
|
|
payload_data = {
|
|
"user_id": str(user.id),
|
|
"email": user.email,
|
|
"tenant_id": str(tenant_id) if tenant_id else None,
|
|
"permissions": permissions,
|
|
"timestamp": timestamp,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"role": user.role.role_name if user.role else None
|
|
}
|
|
|
|
tenant_id_str = str(tenant_id) if tenant_id else ""
|
|
canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}×tamp={timestamp}"
|
|
|
|
try:
|
|
signature = TrustService.sign_payload(env, canonical_string)
|
|
except ValueError:
|
|
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
|
|
|
|
base_url = env.backend_base_url.rstrip('/')
|
|
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
|
|
|
|
if not path.startswith('/'):
|
|
path = '/' + path
|
|
|
|
target_url = f"{base_url}{path}"
|
|
|
|
return {
|
|
"target_url": target_url,
|
|
"payload": payload_data,
|
|
"headers": {
|
|
"X-App-Id": "saas",
|
|
"X-App-Id": module.module_id,
|
|
"X-Signature": signature
|
|
},
|
|
"redirect_url": env.frontend_base_url
|
|
}
|
|
|
|
@staticmethod
|
|
def exchange_grant(
|
|
db: Session,
|
|
grant_code: str,
|
|
module_id: str,
|
|
environment_slug: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
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).
|
|
"""
|
|
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 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_data["user_id"]).first()
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
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": 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",
|
|
"expires_in": 900,
|
|
"user": {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name
|
|
}
|
|
} |