Files
leocrm/app/routes/guest_auth.py
T
Agent Zero 7fbbe420bd
Check Cross-Plugin Imports / check (push) Has been cancelled
fix: comprehensive system audit fixes (55+ issues)
CRITICAL:
- Fix SQL injection in prestart.sh (parameterized query)
- Fix secret key validation (always validate, not just production)
- Fix workspace model partial index bug (func.text -> text)
- Fix HealthResponse schema (add checks field)
- Fix Tenant import in permissions.py (NameError on every auth request)
- Fix README tech stack (React instead of Alpine.js)
- Delete broken test_cross_tenant_security_v2.py
- Add fail-closed RLS migration 0084 (48 tenant tables)

HIGH:
- Add GeneralRateLimitMiddleware for all API routes
- Add file type blocklist for DMS and attachment uploads
- Fix guest auth: Pydantic schema, tenant_slug required, CSRF bypass
- Fix CSRF bypass path matching (in -> endswith)
- Add worker healthcheck in docker-compose.yml
- Add ARQ max_tries=3 for job retries
- Fix 28 bare pass in mail services (-> logger.debug)
- Fix print() -> logger in main.py and ai_assistant
- Fix duplicate email handling (catch IntegrityError -> 409)
- Add session revocation (invalidate_all_user_sessions)
- Add resource limits to all containers
- Fix CORS default (localhost -> production domain)
- Fix SameSite=Lax -> Strict
- Fix Redis password visibility in healthcheck
- Fix npm vulnerabilities (19 -> 9)
- Fix Sidebar OOM (wildcard lucide import -> curated ICON_MAP)

MEDIUM:
- Localize ErrorBoundary to German
- Wire Mail.tsx save/delete filter to API
- Document system_notif plugin (no routes needed)
- Fix datetime.utcnow() -> datetime.now(UTC)
- Pin litellm version (>=1.0,<2.0)
- Move CSRF token from sessionStorage to in-memory
- Fix restore_backup error handling and transaction
- Fix Dms.tsx useEffect cleanup
- Add skip-to-content link for accessibility
- Add selectinload imports to 3 services
- Add .env.example missing variables
- Fix AppShell/TopBar/Sidebar test mocks

NEW TESTS:
- test_guest_auth.py (6 tests)
- test_user_service.py (8 tests)
- test_backup_service.py (5 tests)

NEW SCHEMAS:
- saved_filter, saved_view, user_preference, workspace, entity_policy

Tests: 22/22 PASSED
2026-07-31 00:58:05 +02:00

170 lines
5.0 KiB
Python

"""Guest Auth routes — login, logout for guest users."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, hash_password, verify_password
from app.core.db import get_db
from app.deps import get_current_guest
from app.models.guest_user import GuestUser
from app.models.tenant import Tenant
router = APIRouter(prefix="/api/v1/guest", tags=["guest-auth"])
settings = get_settings()
class GuestLoginRequest(BaseModel):
"""Schema for guest login request."""
email: EmailStr
password: str
tenant_slug: str
@router.post("/login")
async def guest_login(
request: Request,
body: GuestLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Guest login with email+password. Sets guest session cookie."""
email = body.email
password = body.password
tenant_slug = body.tenant_slug
# Find guest user by email — tenant_slug is required to prevent cross-tenant enumeration
tenant_q = await db.execute(
select(Tenant).where(Tenant.slug == tenant_slug)
)
tenant = tenant_q.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
tenant_id = tenant.id
# Find guest with tenant context
guest_q = await db.execute(
select(GuestUser)
.where(GuestUser.email == email)
.where(GuestUser.tenant_id == tenant_id)
.where(GuestUser.status == "active")
)
guest = guest_q.scalar_one_or_none()
if not guest or not guest.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
if not verify_password(password, guest.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Invalid credentials", "code": "invalid_credentials"},
)
# Check expiration
if guest.expires_at and guest.expires_at < datetime.now(UTC):
guest.status = "expired"
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Guest access expired", "code": "guest_expired"},
)
# Create guest session in Redis
redis = get_redis()
session_id = str(uuid.uuid4())
csrf_token = str(uuid.uuid4())
session_data = {
"guest_user_id": str(guest.id),
"tenant_id": str(tenant_id),
"email": guest.email,
"name": guest.name,
"csrf_token": csrf_token,
"is_guest": True,
}
import json
await redis.setex(
f"guest_session:{session_id}",
1800, # 30 min TTL
json.dumps(session_data),
)
# Track session in guest index for revocation (P1.6 fix)
await redis.sadd(f"guest_sessions:{guest.id}", session_id)
await redis.expire(f"guest_sessions:{guest.id}", 1800)
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={
"guest_user_id": str(guest.id),
"email": guest.email,
"name": guest.name,
"tenant_id": str(tenant_id),
"csrf_token": csrf_token,
},
)
resp.set_cookie(
key="guest_session",
value=session_id,
httponly=True,
secure=settings.session_cookie_secure,
samesite=settings.session_cookie_samesite,
max_age=1800,
path="/",
)
return resp
@router.post("/logout")
async def guest_logout(
request: Request,
):
"""Logout — invalidate guest session, clear cookie."""
session_id = request.cookies.get("guest_session")
if session_id:
redis = get_redis()
# Remove from guest sessions index (P1.6 fix)
guest_data = await redis.get(f"guest_session:{session_id}")
if guest_data:
import json
data = json.loads(guest_data)
gid = data.get("guest_user_id")
if gid:
await redis.srem(f"guest_sessions:{gid}", session_id)
await redis.delete(f"guest_session:{session_id}")
from fastapi.responses import JSONResponse
resp = JSONResponse(
status_code=status.HTTP_200_OK,
content={"message": "Logged out"},
)
resp.delete_cookie("guest_session", path="/")
return resp
@router.get("/me")
async def guest_me(
current_guest: dict = Depends(get_current_guest),
):
"""Get current guest user info."""
return {
"guest_user_id": current_guest.get("guest_user_id"),
"email": current_guest.get("email"),
"name": current_guest.get("name"),
"tenant_id": current_guest.get("tenant_id"),
}