Реализация SMS-аутентификации, выбор провайдеров, защита от SIM-swap и других атак.
SMS-коды — удобный метод 2FA, но с уникальными рисками безопасности. Вы научитесь выбирать провайдеров, защищать от SIM-swap и оптимизировать расходы.
Рекомендация: Используйте SMS как опцию, но продвигайте TOTP или FIDO2 как более безопасные методы.
| Провайдер | Регион | Цена за SMS | API |
|---|---|---|---|
| Twilio | Глобально | ~$0.0075 | REST, SDK |
| SMS.ru | Россия, СНГ | ~0.5 руб | REST |
| MessageBird | Глобально | ~$0.006 | REST, SDK |
| Vonage (Nexmo) | Глобально | ~$0.007 | REST, SDK |
| Telesign | Глобально | ~$0.01 | REST |
| Критерий | Почему важен |
|---|---|
| Покрытие | Работает ли в целевых странах, особенно развивающихся |
| Delivery reports | Подтверждение доставки — критично для UX |
| Sender ID | Возможность настроить имя отправителя (брендинг) |
| Fallback | Автоматический переход на Voice Call при недоставке |
| Pricing model | Оплата за доставку или за попытку |
Для международного проекта:
1. Основной провайдер с глобальным покрытием (Twilio, MessageBird)
2. Локальные провайдеры для ключевых рынков (дешевле, лучше доставка)
3. Фолбэк-цепочка: Основной → Резервный → Voice Call
pip install twilio# app/config.py
from pydantic import BaseSettings
class Settings(BaseSettings):
TWILIO_ACCOUNT_SID: str
TWILIO_AUTH_TOKEN: str
TWILIO_PHONE_NUMBER: str
class Config:
env_file = ".env"
settings = Settings()# app/services/sms.py
from twilio.rest import Client
from app.config import settings
import secrets
class SMSService:
def __init__(self):
self.client = Client(
settings.TWILIO_ACCOUNT_SID,
settings.TWILIO_AUTH_TOKEN
)
def generate_code(self, length: int = 6) -> str:
"""Генерирует криптографически стойкий код."""
return ''.join(
secrets.choice('0123456789') for _ in range(length)
)
def send_code(self, phone_number: str, code: str) -> bool:
"""Отправляет SMS с кодом."""
message = f"Ваш код подтверждения: {code}"
try:
self.client.messages.create(
body=message,
from_=settings.TWILIO_PHONE_NUMBER,
to=phone_number
)
return True
except Exception as e:
# Логирование ошибки
print(f"SMS delivery failed: {e}")
return False
sms_service = SMSService()# app/routes/auth.py
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
import redis
import json
router = APIRouter()
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
CODE_EXPIRE_SECONDS = 300 # 5 минут
MAX_ATTEMPTS = 3
class SendSMSCodeRequest(BaseModel):
phone_number: str = Field(..., pattern=r'^\+?[1-9]\d{1,14}$')
class VerifySMSCodeRequest(BaseModel):
phone_number: str
code: str
@router.post("/sms/send")
async def send_sms_code(request: SendSMSCodeRequest, background_tasks: BackgroundTasks):
"""Отправляет SMS с кодом подтверждения."""
phone = request.phone_number
# Rate limiting: не более 3 SMS в час на номер
rate_key = f"sms_rate:{phone}"
rate_count = redis_client.get(rate_key)
if rate_count and int(rate_count) >= 3:
raise HTTPException(
status_code=429,
detail="Слишком много запросов. Попробуйте через час."
)
# Генерация и отправка кода
code = sms_service.generate_code()
# Сохраняем код в Redis с expiration
code_key = f"sms_code:{phone}"
redis_client.setex(
code_key,
CODE_EXPIRE_SECONDS,
json.dumps({"code": code, "attempts": 0})
)
# Отправка (асинхронно — не блокируем request)
background_tasks.add_task(sms_service.send_code, phone, code)
# Увеличиваем счётчик rate limiting
redis_client.incr(rate_key)
redis_client.expire(rate_key, 3600) # 1 час
return {"status": "SMS отправлено", "expires_in": CODE_EXPIRE_SECONDS}
@router.post("/sms/verify")
async def verify_sms_code(request: VerifySMSCodeRequest):
"""Проверяет SMS-код."""
phone = request.phone_number
code_key = f"sms_code:{phone}"
stored = redis_client.get(code_key)
if not stored:
raise HTTPException(status_code=400, detail="Код истёк")
data = json.loads(stored)
if data["attempts"] >= MAX_ATTEMPTS:
redis_client.delete(code_key)
raise HTTPException(status_code=400, detail="Превышено количество попыток")
if data["code"] != request.code:
data["attempts"] += 1
redis_client.setex(code_key, CODE_EXPIRE_SECONDS, json.dumps(data))
raise HTTPException(status_code=400, detail="Неверный код")
redis_client.delete(code_key)
return {"status": "Код подтверждён"}SMS не доставляется в 2-5% случаев:
┌─────────────────┐
│ Отправка SMS │
└────────┬────────┘
│
▼
┌─────────────┐ Нет
│ Доставка за │─────────────┐
│ 30 секунд? │ │
└─────────────┘ │
│ Да ▼
▼ ┌─────────────┐
┌─────────┐ │ Voice Call │
│ Успех │ │ (звонок) │
└─────────┘ └──────┬──────┘
│
▼
┌─────────────┐ Нет
│ Доставка за │─────────────┐
│ 60 секунд? │ │
└─────────────┘ │
│ Да ▼
▼ ┌─────────────┐
┌─────────┐ │ Email │
│ Успех │ │ (резерв) │
└─────────┘ └─────────────┘
from twilio.rest import Client
from twilio.twiml.voice_response import VoiceResponse
import time
class SMSWithFallback:
def __init__(self, sms_service, twilio_client):
self.sms = sms_service
self.client = twilio_client
def send_with_fallback(self, phone: str, code: str) -> str:
"""Отправляет SMS, при неудаче — Voice Call."""
# Попытка SMS
if self.sms.send_code(phone, code):
# Проверяем доставку (Twilio Delivery Reports)
if self.wait_for_delivery(phone, timeout=30):
return "sms"
# Фолбэк на Voice Call
return self.send_voice_code(phone, code)
def send_voice_code(self, phone: str, code: str) -> str:
"""Звонит и произносит код."""
twiml = VoiceResponse()
twiml.say(f"Ваш код подтверждения: {code}", voice='alice')
twiml.say("Повторяю: " + ", ".join(code), voice='alice')
self.client.calls.create(
twiml=twiml.to_xml(),
to=phone,
from_=settings.TWILIO_PHONE_NUMBER
)
return "voice"
def wait_for_delivery(self, phone: str, timeout: int) -> bool:
"""Ждёт подтверждения доставки."""
# Twilio Status Callbacks через webhook
# Упрощённо: проверяем статус сообщения
pass| Сценарий | Стратегия |
|---|---|
| SMS не доставлено 30 сек | Voice Call |
| Voice Call не доставлен | |
| Критичная операция (смена пароля) | SMS + Voice Call одновременно |
| Пользователь в роуминге | Сразу Voice Call (надёжнее) |
SMS стоит денег. При масштабе 100 000 пользователей в месяц:
def get_recommended_2fa_methods(user) -> list:
"""Рекомендует методы в порядке приоритета."""
return [
("fido2", "Аппаратный ключ", 0), # Бесплатно
("totp", "Google Authenticator", 0), # Бесплатно
("email", "Email", 0), # Бесплатно
("sms", "SMS", 0.0075), # $$$
]# Вместо 3 минут → 5 минут
CODE_EXPIRE_SECONDS = 300 # Меньше повторных отправок# 3 SMS в час вместо 10
RATE_LIMIT = {
"per_minute": 1,
"per_hour": 3,
"per_day": 10
}# Мультипровайдер логика
PROVIDERS = {
"US": ["twilio", "vonage"], # Глобальные
"RU": ["sms.ru", "twilio"], # Локальный дешевле
"IN": ["msg91", "twilio"], # Локальный лучше доставка
}
def get_provider(country_code: str):
providers = PROVIDERS.get(country_code, PROVIDERS["US"])
return providers[0] # Первый — предпочтительный| Провайдер | Объём | Цена за SMS | Экономия |
|---|---|---|---|
| Twilio Pay-as-you-go | 1-1000 | $0.0075 | — |
| Twilio Volume | 10 000+ | $0.0055 | 27% |
| Twilio Enterprise | 100 000+ | $0.0040 | 47% |
def calculate_sms_cost(users: int, sms_per_month: int, price: float) -> dict:
"""Считает расходы и экономию от оптимизации."""
base_cost = users * sms_per_month * price
# После оптимизации (50% пользователей на TOTP)
optimized_users = users * 0.5
optimized_cost = optimized_users * sms_per_month * price
return {
"base_monthly": base_cost,
"optimized_monthly": optimized_cost,
"savings": base_cost - optimized_cost,
"savings_percent": ((base_cost - optimized_cost) / base_cost) * 100
}
# Пример: 100 000 пользователей, 2 SMS/мес, $0.0075
# Base: $1 500/мес
# Optimized: $750/мес (50% на TOTP)
# Savings: $750/мес = $9 000/годЗлоумышленник убеждает оператора связи перенести ваш номер на свою SIM-карту. После этого все SMS приходят ему.
class SMSVerificationService:
def __init__(self):
self.phone_history = {} # В production — БД
def detect_sim_swap(self, phone_number: str, device_fingerprint: str) -> bool:
"""Обнаруживает возможный SIM-swap по изменению устройства."""
stored_fingerprint = self.phone_history.get(phone_number)
if stored_fingerprint and stored_fingerprint != device_fingerprint:
# Номер используется с нового устройства
return True
self.phone_history[phone_number] = device_fingerprint
return False
def send_with_warning(self, phone_number: str, code: str):
"""Отправляет SMS с предупреждением."""
message = (
f"Ваш код подтверждения: {code}. "
f"Если вы не запрашивали код, немедленно свяжитесь с поддержкой."
)
sms_service.send_code(phone_number, message)| Мера | Описание |
|---|---|
| Задержка после смены номера | Не разрешать 2FA через SMS 24 часа после смены |
| Уведомления | Отправлять email при смене номера телефона |
| Дополнительная верификация | Требовать голосовой звонок для критичных операций |
| Мониторинг | Отслеживать частые смены SIM |
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@router.post("/sms/send")
@limiter.limit("3/minute;10/hour")
async def send_sms_code(request: Request, phone_number: str):
# ...
pass| Операция | Лимит | Причина |
|---|---|---|
| Отправка кода | 3/мин, 10/час | Защита от спама |
| Проверка кода | 5/мин | Защита от перебора |
| Смена номера | 1/день | Защита от атак |
def block_phone(phone_number: str, reason: str):
"""Блокирует номер при подозрительной активности."""
redis_client.setex(
f"sms_blocked:{phone_number}",
86400, # 24 часа
reason
)
def is_blocked(phone_number: str) -> bool:
return redis_client.exists(f"sms_blocked:{phone_number}")import logging
logger = logging.getLogger("sms_2fa")
def log_sms_event(event_type: str, phone: str, success: bool, details: dict = None):
"""Логирует события SMS-аутентификации."""
logger.info(
f"SMS_2FA: {event_type} | phone={phone} | success={success} | {details}"
)
# Примеры использования
log_sms_event("CODE_SENT", "+79991234567", True, {"provider": "twilio"})
log_sms_event("CODE_VERIFIED", "+79991234567", True)
log_sms_event("CODE_FAILED", "+79991234567", False, {"attempts": 3})
log_sms_event("SIM_SWAP_DETECTED", "+79991234567", False)secrets)random для генерации кодов┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ FastAPI │────▶│ Redis │
│ │ │ (SMS 2FA) │ │ (codes, rate)│
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐ ┌──────────────┐
│ Twilio │────▶│ Telecoms │
│ (API) │ │ (SMS) │
└──────────────┘ └──────────────┘
# .env
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+1234567890
# Redis для хранения кодов и rate limiting
REDIS_URL=redis://localhost:6379/0
# Лимиты
SMS_RATE_LIMIT_PER_MINUTE=3
SMS_CODE_EXPIRE_SECONDS=300
SMS_MAX_VERIFICATION_ATTEMPTS=3Следующая тема: Email-коды: доставка и валидация
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.