"""Flask request authentication decorators for tenant-scoped routes.""" from __future__ import annotations import hmac import secrets from functools import wraps from flask import current_app, g, request from itsdangerous import BadSignature, URLSafeTimedSerializer from ..services.identity import SessionService from ..utils.api_errors import ApiError from .policy import Actor, Role def _session_factory(): factory = current_app.extensions.get("crowdsight_session_factory") if factory is None: raise ApiError("auth_unavailable", 503, "api.internalError") return factory def _csrf_serializer() -> URLSafeTimedSerializer: secret_key = current_app.secret_key if not secret_key: raise ApiError("auth_unavailable", 503, "api.internalError") return URLSafeTimedSerializer(secret_key, salt="crowdsight-csrf") def issue_csrf_token() -> str: return _csrf_serializer().dumps(secrets.token_urlsafe(24)) def _validate_csrf() -> None: if request.method in {"GET", "HEAD", "OPTIONS"}: return cookie_token = request.cookies.get("crowdsight_csrf", "") header_token = request.headers.get("X-CSRF-Token", "") if not cookie_token or not header_token or not hmac.compare_digest(cookie_token, header_token): raise ApiError("csrf_failed", 403, "common.error") try: _csrf_serializer().loads(cookie_token, max_age=SessionService.DEFAULT_TTL_SECONDS) except BadSignature as exc: raise ApiError("csrf_failed", 403, "common.error") from exc def authenticate_readonly_request() -> None: """Authenticate a legacy blueprint without exposing a DB session to the route.""" if getattr(g, "auth_context", None) is not None: return raw_token = request.cookies.get("crowdsight_session", "") with _session_factory()() as db_session: context = SessionService.resolve(db_session, raw_token) if context is None: raise ApiError("unauthorized", 401, "common.unauthorized") _validate_csrf() g.auth_context = context def current_actor() -> Actor: context = getattr(g, "auth_context", None) if context is None: raise ApiError("unauthorized", 401, "common.unauthorized") return Actor( user_id=context.user.id, organization_id=context.organization.id, role=context.membership.role, ) def require_auth(view): @wraps(view) def wrapped(*args, **kwargs): raw_token = request.cookies.get("crowdsight_session", "") with _session_factory()() as db_session: context = SessionService.resolve(db_session, raw_token) if context is None: raise ApiError("unauthorized", 401, "common.unauthorized") _validate_csrf() g.auth_context = context g.db_session = db_session try: response = view(*args, **kwargs) db_session.commit() return response except Exception: db_session.rollback() raise return wrapped def require_roles(*allowed_roles: Role | str): allowed = {role if isinstance(role, Role) else Role(role) for role in allowed_roles} def decorator(view): @wraps(view) def wrapped(*args, **kwargs): actor = current_actor() if actor.role not in allowed: raise ApiError("forbidden", 403, "common.error") return view(*args, **kwargs) return require_auth(wrapped) return decorator