Стандарты FIDO2 и WebAuthn, работа с YubiKey и другими ключами, реализация в Python.
FIDO2/WebAuthn — самый безопасный метод аутентификации. Вы научитесь реализовывать вход с помощью YubiKey и других аппаратных ключей.
FIDO2 — стандарт аутентификации без паролей, разработанный FIDO Alliance. Состоит из двух компонентов:
| Преимущество | Описание |
|---|---|
| Защита от фишинга | Ключи привязаны к домену, не работают на поддельных сайтах |
| Нет общих секретов | Сервер хранит публичный ключ, приватный остаётся на устройстве |
| Удобство | Одно касание ключа вместо ввода кода |
| Биометрия | Поддержка Touch ID, Face ID, Windows Hello |
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Сервер │ │ Браузер │ │ Аутентификатор │
│ (Relying Party)│ │ (Navigator) │ │ (YubiKey) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. Registration Start │ │
│────────────────────────▶│ │
│ │ 2. Create Credential │
│ │────────────────────────▶│
│ │ │
│ │ 3. Public Key + │
│ │ Attestation │
│ │◀────────────────────────│
│ │ │
│ 4. Store Public Key │ │
│◀────────────────────────│ │
│ │ │
│ 5. Authentication Start │ │
│────────────────────────▶│ │
│ │ 6. Get Assertion │
│ │────────────────────────▶│
│ │ │
│ │ 7. Signature │
│ │◀────────────────────────│
│ │ │
│ 8. Verify Signature │ │
│◀────────────────────────│ │
│ │ │
WebAuthn использует асимметричную криптографию:
Приватный ключ (на аутентификаторе) Публичный ключ (на сервере)
│ │
│ Подписывает challenge │
│────────────────────────────────────▶│
│ │
│ │ Проверяет подпись
pip install webauthn# Для работы с CBOR (формат данных WebAuthn)
pip install cbor2
# Для проверки аттестации (опционально)
pip install cryptography# app/config.py
from pydantic import BaseSettings
class Settings(BaseSettings):
WEBAUTHN_RP_NAME: str = "MyApp"
WEBAUTHN_RP_ID: str = "example.com" # Ваш домен
WEBAUTHN_ORIGIN: str = "https://example.com"
WEBAUTHN_CHALLENGE_TIMEOUT: int = 300 # 5 минут
class Config:
env_file = ".env"
settings = Settings()# app/services/webauthn_service.py
from webauthn import (
WebAuthnMakeCredentialOptions,
WebAuthnAssertionOptions,
WebAuthnMakeCredentialResponse,
WebAuthnAssertionResponse,
)
from webauthn.webauthn import AuthenticationRejectedException, RegistrationRejectedException
import secrets
import base64
from app.config import settings
class WebAuthnService:
def __init__(self):
self.rp_name = settings.WEBAUTHN_RP_NAME
self.rp_id = settings.WEBAUTHN_RP_ID
self.origin = settings.WEBAUTHN_ORIGIN
def generate_challenge(self) -> str:
"""Генерирует криптографически стойкий challenge."""
return base64.b64encode(secrets.token_bytes(32)).decode()
def start_registration(self, user_id: str, username: str) -> dict:
"""Шаг 1: Начало регистрации ключа."""
challenge = self.generate_challenge()
options = WebAuthnMakeCredentialOptions(
challenge=challenge,
rp_name=self.rp_name,
rp_id=self.rp_id,
user_id=user_id,
username=username,
timeout=30000, # 30 секунд
attestation="direct", # Требовать аттестацию
credential_public_key_algorithms=[-7, -257] # ES256, RS256
)
return {
"challenge": challenge,
"options": options.registration_dict
}
def complete_registration(self, registration_response: dict, challenge: str) -> dict:
"""Шаг 2: Завершение регистрации."""
try:
webauthn_response = WebAuthnMakeCredentialResponse(
registration_response
)
# Верификация
credential = webauthn_response.verify(
challenge,
self.origin,
self.rp_id,
None # CA для проверки аттестации (опционально)
)
return {
"success": True,
"credential_id": credential.credential_id,
"public_key": credential.public_key,
"sign_count": credential.sign_count
}
except RegistrationRejectedException as e:
return {"success": False, "error": str(e)}
def start_authentication(self, user_credentials: list) -> dict:
"""Шаг 1: Начало аутентификации."""
challenge = self.generate_challenge()
# allow_credentials — список credential_id пользователя
allow_credentials = [
{"type": "public-key", "id": cred["credential_id"]}
for cred in user_credentials
]
options = WebAuthnAssertionOptions(
challenge=challenge,
allow_credentials=allow_credentials,
timeout=30000
)
return {
"challenge": challenge,
"options": options.assertion_dict
}
def complete_authentication(
self,
assertion_response: dict,
challenge: str,
public_key: str,
credential_id: str,
sign_count: int
) -> dict:
"""Шаг 2: Завершение аутентификации."""
try:
webauthn_response = WebAuthnAssertionResponse(
assertion_response
)
# Верификация подписи
credential = webauthn_response.verify(
challenge,
self.origin,
self.rp_id,
public_key,
sign_count
)
return {
"success": True,
"new_sign_count": credential.sign_count
}
except AuthenticationRejectedException as e:
return {"success": False, "error": str(e)}
webauthn_service = WebAuthnService()# app/routes/webauthn.py
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
import redis
import json
router = APIRouter()
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
class StartRegistrationRequest(BaseModel):
username: str
class CompleteRegistrationRequest(BaseModel):
credential: dict # Аттестационный ответ от браузера
@router.post("/webauthn/register/start")
async def start_registration(request: StartRegistrationRequest):
"""Начинает регистрацию нового ключа."""
user_id = f"user_{request.username}" # В production — ID из БД
result = webauthn_service.start_registration(
user_id=user_id,
username=request.username
)
# Сохраняем challenge в Redis для проверки
redis_client.setex(
f"webauthn_challenge:{user_id}",
300,
result["challenge"]
)
return result["options"]
@router.post("/webauthn/register/complete")
async def complete_registration(request: CompleteRegistrationRequest):
"""Завершает регистрацию ключа."""
# Извлекаем challenge из ответа
client_data = json.loads(
base64.b64decode(request.credential.get("clientDataJSON", ""))
)
challenge = client_data.get("challenge")
# Получаем сохранённый challenge
stored_challenge = redis_client.get(f"webauthn_challenge:user_{request.username}")
if not stored_challenge or stored_challenge != challenge:
raise HTTPException(status_code=400, detail="Invalid challenge")
result = webauthn_service.complete_registration(
request.credential,
stored_challenge
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
# В production: сохранить credential_id и public_key в БД
# user_credentials.append({
# "credential_id": result["credential_id"],
# "public_key": result["public_key"],
# "sign_count": result["sign_count"]
# })
redis_client.delete(f"webauthn_challenge:user_{request.username}")
return {"status": "Ключ зарегистрирован"}class StartAuthenticationRequest(BaseModel):
username: str
class CompleteAuthenticationRequest(BaseModel):
credential: dict # Assertion ответ от браузера
@router.post("/webauthn/login/start")
async def start_login(request: StartAuthenticationRequest):
"""Начинает аутентификацию."""
# В production: получить credentials из БД по username
user_credentials = get_user_credentials(request.username)
if not user_credentials:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = webauthn_service.start_authentication(user_credentials)
# Сохраняем challenge
redis_client.setex(
f"webauthn_challenge:login_{request.username}",
300,
result["challenge"]
)
return result["options"]
@router.post("/webauthn/login/complete")
async def complete_login(request: CompleteAuthenticationRequest):
"""Завершает аутентификацию."""
# Извлекаем данные из ответа
client_data = json.loads(
base64.b64decode(request.credential.get("clientDataJSON", ""))
)
challenge = client_data.get("challenge")
credential_id = request.credential.get("id")
# Получаем данные пользователя из БД
user = get_user_by_credential_id(credential_id)
if not user:
raise HTTPException(status_code=404, detail="Ключ не найден")
stored_challenge = redis_client.get(f"webauthn_challenge:login_{user.username}")
if stored_challenge != challenge:
raise HTTPException(status_code=400, detail="Invalid challenge")
result = webauthn_service.complete_authentication(
request.credential,
stored_challenge,
user.public_key,
credential_id,
user.sign_count
)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
# Обновляем sign_count в БД
update_user_sign_count(user.id, result["new_sign_count"])
redis_client.delete(f"webauthn_challenge:login_{user.username}")
# Генерируем сессию или JWT
return {"status": "Аутентификация успешна", "user": user.username}<!-- templates/webauthn/register.html -->
<script>
async function registerCredential() {
// 1. Запрос options у сервера
const startResp = await fetch('/webauthn/register/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: 'alice'})
});
const options = await startResp.json();
// 2. Вызов браузера для создания ключа
const credential = await navigator.credentials.create({
publicKey: options
});
// 3. Отправка результата на сервер
const completeResp = await fetch('/webauthn/register/complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
credential: {
id: credential.id,
rawId: bufferToBase64(credential.rawId),
response: {
clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
attestationObject: bufferToBase64(credential.response.attestationObject)
}
}
})
});
if (completeResp.ok) {
alert('Ключ зарегистрирован!');
}
}
function bufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
</script>
<button onclick="registerCredential()">Зарегистрировать ключ</button><script>
async function authenticate() {
// 1. Запрос options у сервера
const startResp = await fetch('/webauthn/login/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: 'alice'})
});
const options = await startResp.json();
// 2. Вызов браузера для аутентификации
const assertion = await navigator.credentials.get({
publicKey: options
});
// 3. Отправка результата на сервер
const completeResp = await fetch('/webauthn/login/complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
credential: {
id: assertion.id,
rawId: bufferToBase64(assertion.rawId),
response: {
clientDataJSON: bufferToBase64(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64(assertion.response.authenticatorData),
signature: bufferToBase64(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64(assertion.response.userHandle)
: null
}
}
})
});
if (completeResp.ok) {
window.location.href = '/dashboard';
}
}
</script>
<button onclick="authenticate()">Войти с ключом</button># accounts/models.py
from django.db import models
from django.contrib.auth.models import User
class WebAuthnCredential(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
credential_id = models.CharField(max_length=255, unique=True)
public_key = models.TextField()
sign_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "WebAuthn Credential"
verbose_name_plural = "WebAuthn Credentials"# accounts/views.py
from django.shortcuts import render, redirect
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth import login
from django.views.decorators.csrf import csrf_exempt
from .webauthn_service import webauthn_service
from .models import WebAuthnCredential
import redis
import json
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
@require_POST
def start_registration(request):
username = request.POST.get('username')
user_id = f"user_{username}"
result = webauthn_service.start_registration(user_id, username)
redis_client.setex(f"webauthn_challenge:{user_id}", 300, result["challenge"])
return JsonResponse(result["options"])
@require_POST
@csrf_exempt # Для упрощения; в production используйте CSRF
def complete_registration(request):
import base64
data = json.loads(request.body)
credential = data.get("credential")
client_data = json.loads(
base64.b64decode(credential.get("clientDataJSON", ""))
)
challenge = client_data.get("challenge")
username = data.get("username")
stored_challenge = redis_client.get(f"webauthn_challenge:user_{username}")
if stored_challenge != challenge:
return JsonResponse({"error": "Invalid challenge"}, status=400)
result = webauthn_service.complete_registration(credential, stored_challenge)
if not result["success"]:
return JsonResponse({"error": result["error"]}, status=400)
# Сохраняем в БД
WebAuthnCredential.objects.create(
user=request.user,
credential_id=result["credential_id"],
public_key=result["public_key"],
sign_count=result["sign_count"]
)
redis_client.delete(f"webauthn_challenge:user_{username}")
return JsonResponse({"status": "success"})Аттестация — процесс проверки подлинности аутентификатора.
| Тип | Описание |
|---|---|
none | Без аттестации (приватность максимальная) |
indirect | Аттестация через доверенный CA |
direct | Полная аттестация с сертификатом устройства |
from webauthn import WebAuthnMakeCredentialResponse
# При регистрации
webauthn_response = WebAuthnMakeCredentialResponse(registration_response)
# Верификация с проверкой аттестации
credential = webauthn_response.verify(
challenge=challenge,
origin=origin,
rp_id=rp_id,
ca_pem_path="/path/to/yubico_ca.pem" # CA Yubico
)
# credential.attestation_cert содержит сертификатSign Count — счётчик использования ключа, увеличивается с каждой аутентификацией.
# При аутентификации
old_sign_count = user.sign_count
new_sign_count = credential.sign_count
# Защита от клонирования
if new_sign_count <= old_sign_count and old_sign_count > 0:
# Возможное клонирование ключа!
raise SecurityException("Sign count не увеличился — возможно клонирование")
# Обновляем в БД
user.sign_count = new_sign_count
user.save()┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Frontend │────▶│ FastAPI │────▶│ PostgreSQL │
│ (WebAuthn) │ │ (WebAuthn) │ │ (public keys)│
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Redis │
│ (challenges) │
└──────────────┘
# .env
WEBAUTHN_RP_NAME="MyApp"
WEBAUTHN_RP_ID="example.com"
WEBAUTHN_ORIGIN="https://example.com"
WEBAUTHN_CHALLENGE_TIMEOUT=300Следующая тема: Безопасность и production-нюансы
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.