mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-19 02:23:45 +03:00
259 lines
9.1 KiB
Python
259 lines
9.1 KiB
Python
from __future__ import annotations
|
||
|
||
import secrets
|
||
import hashlib
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.security import create_jwt, hash_password, verify_password
|
||
from app.db.session import get_db
|
||
from app.models.otp_session import OtpSession
|
||
from app.models.request import Request as RequestModel
|
||
from app.schemas.public import OtpSend, OtpVerify
|
||
from app.services.rate_limit import get_rate_limiter
|
||
from app.services.sms_service import SmsDeliveryError, send_otp_message
|
||
|
||
router = APIRouter()
|
||
|
||
OTP_TTL_MINUTES = 10
|
||
OTP_MAX_ATTEMPTS = 5
|
||
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
||
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
||
ALLOWED_PURPOSES = {OTP_CREATE_PURPOSE, OTP_VIEW_PURPOSE}
|
||
|
||
|
||
def _now_utc() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _as_utc(dt: datetime | None) -> datetime:
|
||
if dt is None:
|
||
return _now_utc()
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=timezone.utc)
|
||
return dt.astimezone(timezone.utc)
|
||
|
||
|
||
def _normalize_purpose(raw: str | None) -> str:
|
||
return str(raw or "").strip().upper()
|
||
|
||
|
||
def _normalize_phone(raw: str | None) -> str:
|
||
phone = str(raw or "").strip()
|
||
if not phone:
|
||
return ""
|
||
allowed = {"+", "(", ")", "-", " "}
|
||
digits = [ch for ch in phone if ch.isdigit() or ch in allowed]
|
||
return "".join(digits).strip()
|
||
|
||
|
||
def _normalize_track(raw: str | None) -> str:
|
||
return str(raw or "").strip().upper()
|
||
|
||
|
||
def _generate_code() -> str:
|
||
return f"{secrets.randbelow(1_000_000):06d}"
|
||
|
||
|
||
def _client_ip(request: Request) -> str:
|
||
xff = str(request.headers.get("x-forwarded-for") or "").strip()
|
||
if xff:
|
||
first = xff.split(",")[0].strip()
|
||
if first:
|
||
return first
|
||
client = request.client
|
||
return str(client.host if client else "unknown")
|
||
|
||
|
||
def _hash_key_part(value: str | None) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return "-"
|
||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
|
||
|
||
|
||
def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None:
|
||
limiter = get_rate_limiter()
|
||
window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1))
|
||
limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1))
|
||
purpose_norm = str(purpose or "").strip().upper()
|
||
keys = [
|
||
f"otp:{action}:ip:{_hash_key_part(client_ip)}:purpose:{purpose_norm}",
|
||
]
|
||
if phone:
|
||
keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}")
|
||
if track_number:
|
||
keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}")
|
||
|
||
for key in keys:
|
||
result = limiter.hit(key, limit=limit, window_seconds=window)
|
||
if not result.allowed:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"Слишком много OTP-запросов. Повторите через {max(result.retry_after_seconds, 1)} сек.",
|
||
)
|
||
|
||
|
||
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
|
||
token = create_jwt(
|
||
{"sub": subject, "purpose": purpose},
|
||
settings.PUBLIC_JWT_SECRET,
|
||
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
||
)
|
||
response.set_cookie(
|
||
key=settings.PUBLIC_COOKIE_NAME,
|
||
value=token,
|
||
httponly=True,
|
||
secure=False,
|
||
samesite="lax",
|
||
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
||
)
|
||
|
||
|
||
@router.post("/send")
|
||
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||
purpose = _normalize_purpose(payload.purpose)
|
||
if purpose not in ALLOWED_PURPOSES:
|
||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||
|
||
track_number: str | None = None
|
||
phone = ""
|
||
if purpose == OTP_CREATE_PURPOSE:
|
||
phone = _normalize_phone(payload.client_phone)
|
||
if not phone:
|
||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||
else:
|
||
track_number = _normalize_track(payload.track_number)
|
||
phone = _normalize_phone(payload.client_phone)
|
||
if track_number:
|
||
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
||
if request_row is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
phone = _normalize_phone(request_row.client_phone)
|
||
elif phone:
|
||
has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first()
|
||
if has_requests is None:
|
||
raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены")
|
||
else:
|
||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||
if not phone:
|
||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||
|
||
_rate_limit_or_429(
|
||
"send",
|
||
purpose=purpose,
|
||
client_ip=_client_ip(request),
|
||
phone=phone or None,
|
||
track_number=track_number,
|
||
)
|
||
|
||
code = _generate_code()
|
||
try:
|
||
sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||
except SmsDeliveryError as exc:
|
||
raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc
|
||
|
||
now = _now_utc()
|
||
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||
|
||
existing_query = db.query(OtpSession).filter(
|
||
OtpSession.purpose == purpose,
|
||
OtpSession.phone == phone,
|
||
OtpSession.track_number == track_number,
|
||
)
|
||
existing_query.delete(synchronize_session=False)
|
||
|
||
row = OtpSession(
|
||
purpose=purpose,
|
||
track_number=track_number,
|
||
phone=phone,
|
||
code_hash=hash_password(code),
|
||
attempts=0,
|
||
expires_at=expires_at,
|
||
responsible="Система OTP",
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
|
||
return {
|
||
"status": "sent",
|
||
"purpose": purpose,
|
||
"track_number": track_number,
|
||
"ttl_seconds": OTP_TTL_MINUTES * 60,
|
||
"sms_response": sms_response,
|
||
}
|
||
|
||
|
||
@router.post("/verify")
|
||
def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)):
|
||
purpose = _normalize_purpose(payload.purpose)
|
||
if purpose not in ALLOWED_PURPOSES:
|
||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||
|
||
track_number: str | None = None
|
||
phone: str | None = None
|
||
if purpose == OTP_CREATE_PURPOSE:
|
||
phone = _normalize_phone(payload.client_phone)
|
||
if not phone:
|
||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||
else:
|
||
track_number = _normalize_track(payload.track_number)
|
||
phone = _normalize_phone(payload.client_phone)
|
||
if not track_number and not phone:
|
||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||
|
||
_rate_limit_or_429(
|
||
"verify",
|
||
purpose=purpose,
|
||
client_ip=_client_ip(request),
|
||
phone=phone,
|
||
track_number=track_number,
|
||
)
|
||
|
||
query = db.query(OtpSession).filter(OtpSession.purpose == purpose)
|
||
if track_number is not None and track_number != "":
|
||
query = query.filter(OtpSession.track_number == track_number)
|
||
if phone is not None and phone != "":
|
||
query = query.filter(OtpSession.phone == phone)
|
||
|
||
row = query.order_by(OtpSession.created_at.desc()).first()
|
||
if row is None:
|
||
raise HTTPException(status_code=400, detail="OTP не найден или истек")
|
||
|
||
now = _now_utc()
|
||
if _as_utc(row.expires_at) <= now:
|
||
db.delete(row)
|
||
db.commit()
|
||
raise HTTPException(status_code=400, detail="OTP не найден или истек")
|
||
|
||
if int(row.attempts or 0) >= OTP_MAX_ATTEMPTS:
|
||
raise HTTPException(status_code=429, detail="Превышено количество попыток")
|
||
|
||
code = str(payload.code or "").strip()
|
||
if not code or not verify_password(code, row.code_hash):
|
||
row.attempts = int(row.attempts or 0) + 1
|
||
db.add(row)
|
||
db.commit()
|
||
raise HTTPException(status_code=400, detail="Неверный OTP-код")
|
||
|
||
if purpose == OTP_CREATE_PURPOSE:
|
||
subject = str(row.phone or "")
|
||
else:
|
||
if phone:
|
||
subject = str(row.phone or "")
|
||
elif track_number:
|
||
subject = str(row.track_number or "")
|
||
else:
|
||
subject = str(row.phone or row.track_number or "")
|
||
if not subject:
|
||
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
|
||
|
||
_set_public_cookie(response, subject=subject, purpose=purpose)
|
||
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"status": "verified", "purpose": purpose}
|