DealDocumentScreening/src/contract_check/api/routes/auth/magic_links.py

161 lines
5.5 KiB
Python

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=(
"<p>Вы запросили вход по ссылке.</p>"
f'<p><a href="{link}">Войти</a> '
f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)</p>"
"<p>Если вы не запрашивали вход — проигнорируйте это письмо.</p>"
),
)
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)