ppt-tool/backend/services/auth_service.py
Vadym Samoilenko c431d4ab45 Implement critical security fixes and modern design system (Pre-launch P0 tasks)
Security Improvements (P0.0-P0.4):
- P0.0: Migrate to Gemini-only AI stack (simplified, single billing)
- P0.1: Fix CORS to restrict allowed origins from env (was *)
- P0.2: Remove hardcoded dev password, require env var
- P0.3: Add rate limiting (slowapi) - 3-10 req/min on sensitive endpoints
- P0.4: Add request size limits (100MB default via middleware)

New Features:
- Unified LLM service with Google Gemini priority
- OXML geometry extractor for layout parsing
- TSX validator for generated React components
- Client ID support in presentation requests with access control
- Configurable LLM/image timeouts via env vars

Modern Design System (P0.9 - partial):
- Enhanced CSS design tokens (primary, semantic colors, shadows)
- Typography scale (h1-h4, body variants, caption)
- Modern animations (fadeIn, slideIn, scaleIn)
- Updated Button component with better variants and hover effects
- Created unified Card and StatusBadge components
- Applied design system to Dashboard and Settings pages

Backend Improvements:
- Master deck parser simplification
- Slide-to-HTML endpoint cleanup (325 lines removed)
- Better error handling in prompts endpoint

Frontend Improvements:
- Settings UI simplified to show only Google/Gemini
- Dashboard uses CSS variables instead of hardcoded colors
- Improved button transitions and hover states

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-02-27 18:28:24 +00:00

161 lines
5.7 KiB
Python

import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from sqlmodel import select
from models.sql.user import UserModel
from models.sql.team import TeamModel
from models.sql.team_membership import TeamMembershipModel
class AuthService:
def __init__(self):
self.tenant_id = os.getenv("AZURE_AD_TENANT_ID")
self.client_id = os.getenv("AZURE_AD_CLIENT_ID")
self.client_secret = os.getenv("AZURE_AD_CLIENT_SECRET")
self.redirect_uri = os.getenv("AZURE_AD_REDIRECT_URI", "http://localhost/api/v1/auth/callback")
self.jwt_secret = os.getenv("JWT_SECRET_KEY", "dev-secret-change-me")
self.jwt_algorithm = "HS256"
self.jwt_expiry_hours = 24
self.dev_password = os.getenv("DEV_AUTH_PASSWORD")
# Require explicit dev password if using dev mode
if self.is_dev_mode and not self.dev_password:
raise ValueError(
"DEV_AUTH_PASSWORD must be set in .env file when using dev mode. "
"Set AZURE_AD_TENANT_ID to enable Azure AD SSO instead."
)
self._msal_app = None
@property
def is_dev_mode(self) -> bool:
return not self.tenant_id
@property
def msal_app(self):
if self._msal_app is None and not self.is_dev_mode:
import msal
self._msal_app = msal.ConfidentialClientApplication(
self.client_id,
authority=f"https://login.microsoftonline.com/{self.tenant_id}",
client_credential=self.client_secret,
)
return self._msal_app
def get_authorization_url(self) -> str:
if self.is_dev_mode:
raise ValueError("Azure AD not configured — use dev login")
result = self.msal_app.get_authorization_request_url(
scopes=["User.Read"],
redirect_uri=self.redirect_uri,
)
return result
async def exchange_code_for_token(self, code: str) -> dict:
if self.is_dev_mode:
raise ValueError("Azure AD not configured — use dev login")
result = self.msal_app.acquire_token_by_authorization_code(
code,
scopes=["User.Read"],
redirect_uri=self.redirect_uri,
)
if "error" in result:
raise ValueError(f"Token exchange failed: {result.get('error_description', result.get('error'))}")
return result
def create_session_jwt(self, user: UserModel) -> str:
payload = {
"sub": str(user.id),
"email": user.email,
"role": user.role,
"exp": datetime.now(timezone.utc) + timedelta(hours=self.jwt_expiry_hours),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
def validate_token(self, token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, self.jwt_secret, algorithms=[self.jwt_algorithm])
return payload
except JWTError:
return None
async def get_or_create_user(self, claims: dict, session) -> UserModel:
"""Find user by azure_oid or create new one. Auto-adds to Oliver Team."""
azure_oid = claims.get("oid")
email = claims.get("preferred_username") or claims.get("email")
display_name = claims.get("name", email)
# Try find by azure_oid
if azure_oid:
result = await session.execute(
select(UserModel).where(UserModel.azure_oid == azure_oid)
)
user = result.scalar_one_or_none()
if user:
user.last_login_at = datetime.now(timezone.utc)
user.display_name = display_name
session.add(user)
await session.commit()
return user
# Try find by email
result = await session.execute(
select(UserModel).where(UserModel.email == email)
)
user = result.scalar_one_or_none()
if user:
if azure_oid and not user.azure_oid:
user.azure_oid = azure_oid
user.last_login_at = datetime.now(timezone.utc)
user.display_name = display_name
session.add(user)
await session.commit()
return user
# Create new user
user = UserModel(
azure_oid=azure_oid,
email=email,
display_name=display_name,
role="user",
is_active=True,
last_login_at=datetime.now(timezone.utc),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
# Auto-add to Oliver Team
oliver_result = await session.execute(
select(TeamModel).where(TeamModel.is_default == True) # noqa: E712
)
oliver_team = oliver_result.scalar_one_or_none()
if oliver_team:
membership = TeamMembershipModel(
user_id=user.id,
team_id=oliver_team.id,
assigned_at=datetime.now(timezone.utc),
)
session.add(membership)
await session.commit()
return user
async def dev_login(self, email: str, password: str, session) -> Optional[UserModel]:
"""Dev-mode login: validate password, get or create user."""
if not self.is_dev_mode:
return None
if password != self.dev_password:
return None
claims = {
"email": email,
"name": email.split("@")[0].replace(".", " ").title(),
}
return await self.get_or_create_user(claims, session)