Алгоритм TOTP, реализация в FastAPI и Django, интеграция с мобильными приложениями-аутентификаторами.
TOTP — самый популярный метод 2FA. Вы научитесь понимать алгоритм, выбирать правильные параметры и защищать от атак.
TOTP (Time-based One-Time Password) — алгоритм генерации одноразовых паролей на основе:
┌─────────────┐ ┌─────────────┐
│ Сервер │ │ Клиент │
│ │ │ (приложение)│
│ Secret Key │ ──────▶ │ Secret Key │
│ │ (QR) │ │
│ │ │ │
│ Время: T │ │ Время: T │
│ TOTP(T) │ │ TOTP(T) │
│ = │◀───────▶│ = │
│ 123456 │ │ 123456 │
└─────────────┘ └─────────────┘
TOTP(secret, текущее_время)TOTP основан на HOTP (HMAC-based One-Time Password):
import hmac
import hashlib
import struct
import time
def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
# 1. HMAC-SHA1 от счётчика
hmac_hash = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
# 2. Dynamic Truncation
offset = hmac_hash[-1] & 0x0F
code = struct.unpack(">I", hmac_hash[offset:offset+4])[0] & 0x7FFFFFFF
# 3. Модуль для нужного количества цифр
return str(code % (10 ** digits)).zfill(digits)
def totp(secret: bytes, time_step: int = 30, digits: int = 6) -> str:
# Счётчик = текущее время / шаг времени
counter = int(time.time()) // time_step
return hotp(secret, counter, digits)HMAC гарантирует, что:
pip install pyotp qrcode[pil]import pyotp
import qrcode
from io import BytesIO
import base64
def generate_secret() -> str:
"""Генерирует случайный Base32-секрет для TOTP."""
return pyotp.random_base32()
def get_provisioning_uri(secret: str, username: str, issuer: str) -> str:
"""Создаёт otpauth:// URI для QR-кода."""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=issuer)
def generate_qr_code(provisioning_uri: str) -> str:
"""Генерирует QR-код как base64-строку для отображения в HTML."""
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode()from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import pyotp
router = APIRouter()
class Enable2FARequest(BaseModel):
user_id: int
class Verify2FARequest(BaseModel):
user_id: int
code: str
# Временное хранилище секретов (в production — БД!)
temp_secrets = {}
@router.post("/2fa/enable")
async def enable_2fa(request: Enable2FARequest):
"""Шаг 1: Генерирует секрет и QR-код для настройки 2FA."""
secret = pyotp.random_base32()
provisioning_uri = pyotp.TOTP(secret).provisioning_uri(
name=f"user_{request.user_id}@example.com",
issuer_name="MyApp"
)
temp_secrets[request.user_id] = secret
return {
"secret": secret,
"qr_code": generate_qr_code(provisioning_uri),
"provisioning_uri": provisioning_uri
}
@router.post("/2fa/verify")
async def verify_2fa(request: Verify2FARequest):
"""Шаг 2: Проверяет код и активирует 2FA."""
secret = temp_secrets.get(request.user_id)
if not secret:
raise HTTPException(status_code=400, detail="2FA не инициирована")
totp = pyotp.TOTP(secret)
# Проверяем код с допуском ±1 окно (защита от рассинхронизации времени)
if not totp.verify(request.code, valid_window=1):
raise HTTPException(status_code=400, detail="Неверный код")
# В production: сохранить secret в БД, пометить пользователя как 2FA-enabled
temp_secrets.pop(request.user_id)
return {"status": "2FA активирована"}class LoginRequest(BaseModel):
username: str
password: str
totp_code: str | None = None
@router.post("/login")
async def login(request: LoginRequest):
# 1. Проверяем логин/пароль
user = await authenticate_user(request.username, request.password)
if not user:
raise HTTPException(status_code=401, detail="Неверные учётные данные")
# 2. Если у пользователя включён 2FA — проверяем код
if user.totp_secret:
if not request.totp_code:
raise HTTPException(
status_code=400,
detail="Требуется код 2FA",
headers={"X-2FA-Required": "true"}
)
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(request.totp_code, valid_window=1):
raise HTTPException(status_code=401, detail="Неверный код 2FA")
# 3. Генерируем JWT или сессию
return create_access_token(user)pip install django-two-factor-auth# settings.py
INSTALLED_APPS = [
'django_otp',
'django_otp.plugins.otp_totp',
'django_otp.plugins.otp_static', # backup-коды
'two_factor',
]
MIDDLEWARE = [
'django_otp.middleware.OTPMiddleware',
]
LOGIN_URL = 'two_factor:login'# urls.py
from django.urls import path, include
urlpatterns = [
path('', include('two_factor.urls')),
]Примечание:
django-two-factor-authпредоставляет готовый UI, TOTP, SMS, Email и backup-коды. Для большинства проектов это предпочтительный вариант.
# accounts/totp.py
import pyotp
from django.conf import settings
from django.core.cache import cache
def generate_totp_secret() -> str:
return pyotp.random_base32()
def get_totp_uri(secret: str, username: str) -> str:
return pyotp.TOTP(secret).provisioning_uri(
name=username,
issuer_name=settings.SITE_NAME
)
def verify_totp(secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)# accounts/models.py
from django.db import models
from django.contrib.auth.models import User
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
totp_secret = models.CharField(max_length=32, null=True, blank=True)
is_2fa_enabled = models.BooleanField(default=False)
def enable_2fa(self, secret: str):
self.totp_secret = secret
self.is_2fa_enabled = True
self.save()
def disable_2fa(self):
self.totp_secret = None
self.is_2fa_enabled = False
self.save()| Угроза | Защита | Механизм |
|---|---|---|
| Кража пароля | ✅ Защищает | Злоумышленник без TOTP-кода не войдёт |
| Brute force пароля | ✅ Защищает | Даже зная пароль, нужен код |
| Replay-атака | ⚠️ Частично | Код действителен 30 секунд, можно отслеживать использованные |
| Фишинг | ❌ Не защищает | Код работает на любом домене |
| MITM (перехват трафика) | ⚠️ Частично | HTTPS обязателен, иначе код перехватят |
| Компрометация сервера | ❌ Не защищает | При утечке БД секреты могут быть украдены |
Пользователь на фишинговом сайте:
1. Вводит логин/пароль → злоумышленник получает
2. Вводит TOTP-код → злоумышленник использует для входа на настоящий сайт
3. TOTP не имеет привязки к домену → код работает везде
FIDO2/WebAuthn защищает: ключ подписывает challenge с указанием origin,
и не сработает на поддельном домене.
Часы на сервере и телефоне могут расходиться:
valid_windowtotp = pyotp.TOTP(secret)
# Проверяем текущее окно + предыдущее и следующее (±30 секунд)
is_valid = totp.verify(code, valid_window=1)
# valid_window=1 означает:
# - текущее время T
# - T-1 (предыдущие 30 сек)
# - T+1 (следующие 30 сек)| Параметр | Значение | Обоснование |
|---|---|---|
time_step | 30 секунд | Стандарт RFC 6238, баланс UX/безопасность |
valid_window | 1 | Допуск ±30 секунд для рассинхронизации |
digits | 6 | Стандарт (1 000 000 комбинаций) |
Важно:
valid_window=1не снижает безопасность — код всё равно действителен только 30 секунд, просто сервер принимает соседние окна.
| Ситуация | Действие |
|---|---|
| Плановая ротация | Каждые 90-180 дней (compliance) |
| Утечка БД | Немедленно всех пользователей |
| Подозрение на компрометацию | Для конкретного пользователя |
| Отключение 2FA пользователем | Удалить секрет |
class TOTPService:
def rotate_secret(self, user_id: int) -> dict:
"""Генерирует новый секрет, требует подтверждения."""
new_secret = pyotp.random_base32()
new_uri = pyotp.TOTP(new_secret).provisioning_uri(
name=user.email,
issuer_name="MyApp"
)
# Сохраняем временно, требуем подтверждения
cache.setex(
f"totp_rotation:{user_id}",
600, # 10 минут на подтверждение
json.dumps({"new_secret": new_secret})
)
return {
"qr_code": generate_qr_code(new_uri),
"requires_confirmation": True
}
def confirm_rotation(self, user_id: int, code: str) -> bool:
"""Подтверждает ротацию кодом от НОВОГО секрета."""
rotation_data = cache.get(f"totp_rotation:{user_id}")
if not rotation_data:
return False
new_secret = rotation_data["new_secret"]
totp = pyotp.TOTP(new_secret)
if totp.verify(code, valid_window=1):
# Применяем новый секрет
user.totp_secret = new_secret
user.save()
cache.delete(f"totp_rotation:{user_id}")
return True
return FalseПроблема: Что если пользователь потерял телефон с Google Authenticator?
Решение: Backup-коды — одноразовые коды для аварийного входа.
import secrets
import string
def generate_backup_codes(count: int = 10, length: int = 8) -> list[str]:
"""Генерирует список одноразовых backup-кодов."""
alphabet = string.ascii_lowercase + string.digits
codes = []
for _ in range(count):
code = ''.join(secrets.choice(alphabet) for _ in range(length))
codes.append(code)
return codesfrom passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class UserProfile:
def __init__(self):
self.backup_codes_hashed = [] # Хэши backup-кодов
def set_backup_codes(self, plain_codes: list[str]):
"""Хэширует и сохраняет backup-коды."""
self.backup_codes_hashed = [
pwd_context.hash(code) for code in plain_codes
]
def consume_backup_code(self, code: str) -> bool:
"""Проверяет и удаляет использованный код."""
for i, hashed in enumerate(self.backup_codes_hashed):
if pwd_context.verify(code, hashed):
self.backup_codes_hashed.pop(i) # Удаляем использованный
return True
return FalseПроблема: При утечке БД злоумышленник получит все TOTP-секреты.
Решение: Шифрование на уровне приложения.
from cryptography.fernet import Fernet
class EncryptedTOTP:
def __init__(self, encryption_key: bytes):
self.cipher = Fernet(encryption_key)
def encrypt(self, secret: str) -> bytes:
return self.cipher.encrypt(secret.encode())
def decrypt(self, encrypted: bytes) -> str:
return self.cipher.decrypt(encrypted).decode()
# В settings.py
TOTP_ENCRYPTION_KEY = os.environ.get("TOTP_ENCRYPTION_KEY") # Fernet-ключ
# В модели
encrypted_secret = cipher.encrypt(totp_secret)
# ... сохранить в БД ...
totp_secret = cipher.decrypt(encrypted_secret)| Практика | Описание |
|---|---|
| Хранение ключа | Переменные окружения, не в коде |
| Ротация ключа | Каждые 90 дней, перешифровка данных |
| Доступ | Только app, не DBA |
| Backup | Secure vault (AWS KMS, HashiCorp Vault) |
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ FastAPI │────▶│ PostgreSQL │
│ (React) │ │ (TOTP) │ │ (секреты) │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Redis │
│ (rate limit) │
└──────────────┘
valid_window=1 для толерантности к времени| Метод | Безопасность | UX | Стоимость | Фишинг |
|---|---|---|---|---|
| TOTP | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Бесплатно | ❌ Уязвим |
| SMS | ⭐⭐ | ⭐⭐⭐⭐⭐ | $$$ | ❌ Уязвим |
| ⭐⭐⭐ | ⭐⭐⭐⭐ | Бесплатно | ❌ Уязвим | |
| FIDO2 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Средняя | ✅ Защищён |
Для production:
1. FIDO2/WebAuthn — основной метод (максимальная безопасность)
2. TOTP — альтернатива для технических пользователей
3. SMS/Email — резерв для пользователей без смартфона
4. Backup-коды — аварийный доступ
Следующая тема: SMS-коды: интеграция и безопасность
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.