Files
leocrm/app/routes/guests.py
T

251 lines
8.1 KiB
Python

"""Guest management routes — invite, list, delete guests (admin only).
Uses secure invitation tokens (P1.6 fix):
- Token is a random 32-byte URL-safe string (secrets.token_urlsafe)
- Only the SHA-256 hash is stored in the database
- One-time use: used_at is set on acceptance
- Session revocation via Redis on guest deletion
"""
from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
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
from app.core.db import get_db
from app.deps import get_current_user, require_admin
from app.models.guest_user import GuestUser
from app.models.guest_invitation import GuestInvitation
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
settings = get_settings()
def _hash_token(token: str) -> str:
"""Hash a token using SHA-256."""
return hashlib.sha256(token.encode()).hexdigest()
@router.post("/invite")
async def invite_guest(
request: Request,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Invite a guest user. Admin only. Returns a secure invitation token."""
email = body.get("email", "")
name = body.get("name", "")
expires_in_hours = body.get("expires_in_hours", 72)
if not email or not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Email and name required", "code": "missing_fields"},
)
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
# Check if guest already exists for this tenant
existing_q = await db.execute(
select(GuestUser)
.where(GuestUser.email == email)
.where(GuestUser.tenant_id == tenant_id)
)
existing = existing_q.scalar_one_or_none()
if existing:
if existing.status == "active":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"detail": "Guest already active", "code": "guest_exists"},
)
# Re-invite: update existing record and create new token
existing.name = name
existing.status = "invited"
existing.invited_by = user_id
existing.expires_at = expires_at
existing.password_hash = None
await db.flush()
guest = existing
else:
guest = GuestUser(
email=email,
name=name,
tenant_id=tenant_id,
invited_by=user_id,
status="invited",
expires_at=expires_at,
)
db.add(guest)
await db.flush()
# Generate secure invitation token
raw_token = secrets.token_urlsafe(32)
token_hash = _hash_token(raw_token)
invitation = GuestInvitation(
guest_user_id=guest.id,
token_hash=token_hash,
expires_at=expires_at,
created_by=user_id,
)
db.add(invitation)
await db.commit()
await db.refresh(guest)
return {
"id": str(guest.id),
"email": guest.email,
"name": guest.name,
"status": guest.status,
"expires_at": guest.expires_at.isoformat() if guest.expires_at else None,
"invitation_token": raw_token, # Only returned once — not stored in plaintext
}
@router.post("/accept/{token}")
async def accept_invitation(
token: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Guest accepts invitation and sets password. Token is one-time use."""
password = body.get("password", "")
if not password or len(password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Password must be at least 8 characters", "code": "weak_password"},
)
# Hash the token and look up the invitation
token_hash = _hash_token(token)
inv_q = await db.execute(
select(GuestInvitation)
.where(GuestInvitation.token_hash == token_hash)
.where(GuestInvitation.used_at.is_(None))
.where(GuestInvitation.revoked_at.is_(None))
)
invitation = inv_q.scalar_one_or_none()
if not invitation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Invitation not found, already used, or revoked", "code": "invitation_not_found"},
)
# Check expiration
if invitation.expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={"detail": "Invitation expired", "code": "invitation_expired"},
)
# Load guest
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == invitation.guest_user_id)
)
guest = guest_q.scalar_one_or_none()
if not guest or guest.status != "invited":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Guest account not found or already active", "code": "guest_not_found"},
)
# Set password and activate
guest.password_hash = hash_password(password)
guest.status = "active"
invitation.used_at = datetime.now(UTC) # One-time use
await db.commit()
return {"message": "Invitation accepted", "status": "active"}
@router.get("")
async def list_guests(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""List all guest users for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(GuestUser)
.where(GuestUser.tenant_id == tenant_id)
.order_by(GuestUser.created_at.desc())
)
guests = result.scalars().all()
return [
{
"id": str(g.id),
"email": g.email,
"name": g.name,
"status": g.status,
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
"created_at": g.created_at.isoformat() if g.created_at else None,
}
for g in guests
]
@router.delete("/{guest_id}")
async def delete_guest(
guest_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete/revoke a guest user and invalidate all sessions."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
gid = uuid.UUID(guest_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
)
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_id)
)
guest = guest_q.scalar_one_or_none()
if not guest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Guest not found", "code": "not_found"},
)
# Revoke: mark as revoked and clear password
guest.status = "revoked"
guest.password_hash = None
# Revoke all pending invitations
from sqlalchemy import update
await db.execute(
update(GuestInvitation)
.where(GuestInvitation.guest_user_id == gid)
.where(GuestInvitation.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)
await db.commit()
# Invalidate all active guest sessions via Redis
redis = get_redis()
if redis:
# Find and delete all guest sessions for this user
# Session keys are stored as session:{session_id} with guest_user_id inside
# We use a Redis index: guest_sessions:{guest_user_id} → set of session_ids
session_key = f"guest_sessions:{gid}"
session_ids = await redis.smembers(session_key)
if session_ids:
for sid in session_ids:
await redis.delete(f"session:{sid}")
await redis.delete(session_key)
return {"message": "Guest revoked, all sessions invalidated", "status": "revoked"}