mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
from __future__ import annotations
|
||
|
||
import secrets
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, 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
|
||
from app.schemas.public import OtpSend, OtpVerify
|
||
|
||
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 _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,
|
||
)
|
||
|
||
|
||
def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None = None) -> dict:
|
||
# Dev-only behavior: emit OTP in console instead of sending real SMS.
|
||
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
||
return {
|
||
"provider": "mock_sms",
|
||
"status": "accepted",
|
||
"message": "SMS provider response mocked",
|
||
}
|
||
|
||
|
||
@router.post("/send")
|
||
def send_otp(payload: OtpSend, 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)
|
||
if not track_number:
|
||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||
request = db.query(Request).filter(Request.track_number == track_number).first()
|
||
if request is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
phone = _normalize_phone(request.client_phone)
|
||
if not phone:
|
||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||
|
||
code = _generate_code()
|
||
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)
|
||
|
||
sms_response = _mock_sms_send(phone, code, purpose, track_number)
|
||
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, 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)
|
||
if not track_number:
|
||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||
|
||
query = db.query(OtpSession).filter(
|
||
OtpSession.purpose == purpose,
|
||
OtpSession.track_number == track_number,
|
||
)
|
||
if phone is not None:
|
||
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-код")
|
||
|
||
subject = row.phone if purpose == OTP_CREATE_PURPOSE else str(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}
|