Files
multi-seal-mail/server/app/api/v1/auth.py

129 lines
4.9 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.v1.schemas import GroupInfo, LoginRequest, LoginResponse, MeResponse, RoleInfo, TenantInfo, TenantMembershipInfo, UserInfo
from app.auth.dependencies import ApiPrincipal, get_api_principal
from app.db.models import Tenant, User
from app.db.session import get_session
from app.security.passwords import verify_password
from app.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(id=tenant.id, slug=tenant.slug, name=tenant.name)
def _user_info(user: User) -> UserInfo:
return UserInfo(id=user.id, email=user.email, display_name=user.display_name, is_tenant_admin=user.is_tenant_admin)
def _roles_info(roles) -> list[RoleInfo]:
return [RoleInfo(id=r.id, slug=r.slug, name=r.name, permissions=r.permissions or []) for r in roles]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=g.id, slug=g.slug, name=g.name) for g in groups]
def _tenant_memberships_for_email(session: Session, email: str) -> list[TenantMembershipInfo]:
"""Return tenants that currently contain an active user with this email.
The current data model still stores users inside one tenant. Until a dedicated
cross-tenant identity table exists, matching by email is the lightweight bridge
that lets the frontend render tenant context and later expose switching only
when more than one membership exists.
"""
rows = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == email, User.is_active.is_(True), Tenant.is_active.is_(True))
.order_by(Tenant.name.asc())
.all()
)
memberships: list[TenantMembershipInfo] = []
for user, tenant in rows:
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
roles=[role.slug for role in roles],
is_active=True,
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[User, Tenant]:
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == payload.email, User.is_active.is_(True), Tenant.is_active.is_(True))
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
rows = query.order_by(Tenant.name.asc()).all()
for user, tenant in rows:
if user.password_hash and verify_password(payload.password, user.password_hash):
return user, tenant
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, session: Session = Depends(get_session)):
user, tenant = _resolve_login_user(session, payload)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(session, user=user, user_agent=user_agent, ip_address=ip_address)
roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user)
scopes = collect_user_scopes(session, user)
tenants = _tenant_memberships_for_email(session, user.email)
session.commit()
active_tenant = _tenant_info(tenant)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
user=_user_info(user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=tenants,
scopes=scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
roles = collect_user_roles(session, principal.user)
groups = collect_user_groups(session, principal.user)
active_tenant = _tenant_info(tenant)
return MeResponse(
user=_user_info(principal.user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=_tenant_memberships_for_email(session, principal.user.email),
scopes=principal.scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.post("/logout")
def logout(request: Request, session: Session = Depends(get_session)):
authorization = request.headers.get("authorization") or ""
if authorization.lower().startswith("bearer "):
revoke_auth_session(session, authorization[7:].strip())
session.commit()
return {"ok": True}