Files
gogo-telefon/gogo/auth.py
Senad Uka 646917c322 M3: chat widget, owner dashboard, super-admin panel
- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
  WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
  and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
  admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
  call/chat history with transcripts, usage bar; settings for profile,
  working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
  cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
  read-only + calendar mappings + buffers, partner status), telephony
  (forwarding MMI codes per operator, ring group, worker SIP creds + QR),
  agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
  partner API config (encrypted secrets), number/SIM registry + assignment,
  connectivity test (live availability + dry-run push), versioned prompt
  template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
  path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
  admin verified live in a browser against Postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:51:57 +02:00

107 lines
3.5 KiB
Python

"""Session-based auth (§4): owner accounts (one per salon) + super-admins.
No public registration — accounts are created by the super-admin. Sessions are
signed cookies (itsdangerous). Admins can impersonate a tenant ("open dashboard
as salon") for support/onboarding (§11).
"""
from __future__ import annotations
import uuid
from typing import Any
import bcrypt
from fastapi import Depends, HTTPException, Request, Response
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from gogo.config import get_settings
from gogo.db import get_session
from gogo.models import Admin, Tenant, User
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except ValueError:
return False
def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(get_settings().secret_key, salt="session")
def set_session(response: Response, data: dict[str, Any]) -> None:
s = get_settings()
response.set_cookie(
s.session_cookie,
_serializer().dumps(data),
max_age=s.session_max_age,
httponly=True,
samesite="lax",
)
def clear_session(response: Response) -> None:
response.delete_cookie(get_settings().session_cookie)
def read_session(request: Request) -> dict[str, Any] | None:
raw = request.cookies.get(get_settings().session_cookie)
if not raw:
return None
try:
return _serializer().loads(raw, max_age=get_settings().session_max_age)
except BadSignature:
return None
class OwnerContext:
def __init__(self, user: User | None, tenant: Tenant, impersonated_by: str | None = None):
self.user = user
self.tenant = tenant
self.impersonated_by = impersonated_by # admin email when impersonating
async def current_owner(
request: Request, session: AsyncSession = Depends(get_session)
) -> OwnerContext:
"""Dependency: require an owner session (or an admin impersonating one)."""
data = read_session(request)
if data and data.get("kind") == "owner":
user = (
await session.execute(select(User).where(User.id == uuid.UUID(data["user_id"])))
).scalar_one_or_none()
if user and user.active:
tenant = (
await session.execute(select(Tenant).where(Tenant.id == user.tenant_id))
).scalar_one()
return OwnerContext(user, tenant)
if data and data.get("kind") == "admin" and data.get("impersonate_tenant"):
tenant = (
await session.execute(
select(Tenant).where(Tenant.id == uuid.UUID(data["impersonate_tenant"]))
)
).scalar_one_or_none()
if tenant:
return OwnerContext(None, tenant, impersonated_by=data.get("email"))
raise HTTPException(status_code=303, headers={"Location": "/login"})
async def current_admin(
request: Request, session: AsyncSession = Depends(get_session)
) -> Admin:
data = read_session(request)
if data and data.get("kind") == "admin":
admin = (
await session.execute(select(Admin).where(Admin.id == uuid.UUID(data["admin_id"])))
).scalar_one_or_none()
if admin and admin.active:
return admin
raise HTTPException(status_code=303, headers={"Location": "/admin/login"})