added backends, improved templating, rbac
This commit is contained in:
128
server/app/api/v1/auth.py
Normal file
128
server/app/api/v1/auth.py
Normal file
@@ -0,0 +1,128 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user