161 lines
5.5 KiB
Python
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)
|