from __future__ import annotations import datetime as dt import secrets import uuid from fastapi import APIRouter, HTTPException, Request, status from src.contract_check.api.deps import ( AsyncSessionDep, NotificationPublisherDep, RateLimiterDep, fetch_user_by_email, fetch_user_by_id_full, require_auth_rate_limit, ) from src.contract_check.api.routes.auth.support import ( _build_magic_link, _hash_reset_token, _issue_single_token, _require_magic_link_enabled, ) from src.contract_check.api.schemas import ( MagicLinkRequest, MagicLinkResponse, MagicLinkVerifyRequest, SingleTokenAuthResponse, ) from src.contract_check.core.config import get_settings from src.contract_check.core.db.repositories import UserRepository from src.contract_check.core.logging import get_logger from src.contract_check.core.mq.messages import NotificationMessage log = get_logger(__name__) router = APIRouter(tags=["auth"]) @router.post( "/magic-link/request", response_model=MagicLinkResponse, status_code=status.HTTP_200_OK, ) async def magic_link_request( request: Request, session: AsyncSessionDep, publisher: NotificationPublisherDep, rate_limiter: RateLimiterDep, body: MagicLinkRequest, ) -> MagicLinkResponse: """Generate a one-time login token, store its hash, and enqueue a notification. Always returns the same 200 payload regardless of whether the email exists, to avoid leaking which addresses are registered (mirrors forgot-password). """ _require_magic_link_enabled() email_normalized = body.email.lower().strip() await require_auth_rate_limit(request, rate_limiter, email=email_normalized) settings = get_settings() generic = MagicLinkResponse( success=True, message="if the email exists, a magic link was sent", expires_in=settings.magic_link_ttl_minutes * 60, ) user = await fetch_user_by_email(session, email_normalized) if user is None: log.info("magic_link_unknown_email", email=email_normalized) return generic raw_token = secrets.token_urlsafe(32) expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=settings.magic_link_ttl_minutes) await UserRepository(session).set_magic_link_token( user.id, token_hash=_hash_reset_token(raw_token), expires_at=expires_at, ) await session.commit() link = _build_magic_link(raw_token) notification = NotificationMessage( correlation_id=uuid.uuid4(), kind="magic_link", to=email_normalized, subject="Вход по ссылке — Контракт-чек", body_text=( "Вы запросили вход по ссылке.\n\n" f"Перейдите по ссылке, чтобы войти (действует " f"{settings.magic_link_ttl_minutes} мин.):\n{link}\n\n" "Ссылка одноразовая. Если вы не запрашивали вход — просто " "проигнорируйте это письмо." ), body_html=( "

Вы запросили вход по ссылке.

" f'

Войти ' f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)

" "

Если вы не запрашивали вход — проигнорируйте это письмо.

" ), ) try: await publisher.publish(notification, routing_key="notify") except Exception as exc: # noqa: BLE001 — best-effort; token is still storable log.error( "magic_link_publish_failed", user_id=str(user.id), error=str(exc), ) log.info("magic_link_enqueued", user_id=str(user.id)) return generic @router.post( "/magic-link/verify", response_model=SingleTokenAuthResponse, status_code=status.HTTP_200_OK, ) async def magic_link_verify( request: Request, session: AsyncSessionDep, rate_limiter: RateLimiterDep, body: MagicLinkVerifyRequest, ) -> SingleTokenAuthResponse: """Exchange a magic-link token for an access JWT. On success the stored token hash is cleared, so every link works exactly once. Expired tokens are also cleared (they can never be replayed). """ _require_magic_link_enabled() await require_auth_rate_limit(request, rate_limiter) token_hash = _hash_reset_token(body.token) users = UserRepository(session) token_user = await users.get_by_magic_link_token_hash(token_hash) if token_user is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="invalid magic-link token" ) user_id = token_user.id expires_at = token_user.magic_link_expires_at now = dt.datetime.now(tz=dt.UTC) if expires_at is None or expires_at < now: await users.consume_magic_link_token(user_id) await session.commit() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="magic-link token expired" ) await users.consume_magic_link_token(user_id) await session.commit() user = await fetch_user_by_id_full(session, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") if not user.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") log.info("user_logged_in_magic_link", user_id=str(user.id)) return _issue_single_token(user)