Exercices - Chapitre 5: Authentification
Mettez en pratique vos connaissances sur les méthodes d'authentification sécurisées
Exercice 5.1 : Authentification par mot de passe
Le stockage sécurisé des mots de passe est essentiel pour protéger les données des utilisateurs. Dans cet exercice, vous allez implémenter et analyser différentes méthodes de stockage de mots de passe.
Partie A : Analyse de code
Examinez le code suivant qui implémente un système d'authentification basique :
import sqlite3
import hashlib
class UserAuth:
def __init__(self, db_path='users.db'):
self.conn = sqlite3.connect(db_path)
self.create_table()
def create_table(self):
cursor = self.conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE,
password TEXT,
email TEXT
)
''')
self.conn.commit()
def register_user(self, username, password, email):
# Hash the password using MD5
hashed_password = hashlib.md5(password.encode()).hexdigest()
cursor = self.conn.cursor()
try:
cursor.execute(
"INSERT INTO users (username, password, email) VALUES (?, ?, ?)",
(username, hashed_password, email)
)
self.conn.commit()
return True
except sqlite3.IntegrityError:
return False
def authenticate_user(self, username, password):
# Hash the provided password
hashed_password = hashlib.md5(password.encode()).hexdigest()
cursor = self.conn.cursor()
cursor.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, hashed_password)
)
user = cursor.fetchone()
return user is not None
Identifiez au moins cinq problèmes de sécurité dans ce code et expliquez comment les corriger.
Partie B : Implémentation sécurisée
Réimplémentez la fonction register_user
et authenticate_user
en utilisant des techniques modernes et sécurisées de stockage de mots de passe.
Partie C : Authentification multi-facteurs
Proposez une extension de la classe UserAuth
pour implémenter une authentification à deux facteurs basée sur des codes TOTP (Time-based One-Time Password). Expliquez comment vous intégreriez cette fonctionnalité et quelles bibliothèques Python vous utiliseriez.
Solution
Partie A : Analyse de code
Voici les problèmes de sécurité identifiés dans le code et les solutions pour les corriger :
-
Utilisation de MD5 : MD5 est une fonction de hachage cryptographiquement faible et considérée comme cassée. Elle est vulnérable aux attaques par collision et aux attaques par force brute accélérées.
Solution : Utiliser une fonction de dérivation de clé moderne comme
bcrypt
,Argon2
ouPBKDF2
qui sont conçues pour le stockage sécurisé des mots de passe. -
Absence de salage : Le code n'utilise pas de sel unique pour chaque utilisateur, ce qui rend les mots de passe vulnérables aux attaques par table arc-en-ciel et aux attaques par dictionnaire précalculé.
Solution : Générer un sel cryptographiquement aléatoire unique pour chaque utilisateur et le stocker avec le hash.
-
Protection insuffisante contre les injections SQL : Bien que le code utilise des requêtes paramétrées, ce qui est bon, il n'y a pas de validation des entrées ni de nettoyage.
Solution : Ajouter une validation des entrées pour s'assurer que les noms d'utilisateur et les emails sont dans un format valide.
-
Absence de gestion des exceptions spécifiques : Le code ne gère que les erreurs d'intégrité, mais pas les autres exceptions potentielles comme les erreurs de connexion à la base de données.
Solution : Implémenter une gestion d'erreurs plus complète avec try/except pour capturer différents types d'exceptions.
-
Pas de limitation de tentatives : Il n'y a aucun mécanisme pour limiter le nombre de tentatives d'authentification, ce qui rend le système vulnérable aux attaques par force brute.
Solution : Ajouter un mécanisme de limitation des tentatives (throttling) ou de verrouillage temporaire des comptes après plusieurs échecs.
-
Absence de politique de mots de passe : Rien n'oblige les utilisateurs à choisir des mots de passe forts.
Solution : Implémenter une vérification de la robustesse des mots de passe.
-
Délai d'authentification constant : La fonction d'authentification s'exécute à des vitesses différentes selon que l'utilisateur existe ou non, ce qui peut permettre des attaques temporelles.
Solution : Assurer que la fonction d'authentification prend toujours le même temps, indépendamment de l'existence de l'utilisateur.
Partie B : Implémentation sécurisée
Voici une implémentation sécurisée utilisant bcrypt :
import sqlite3
import bcrypt
import secrets
import re
import time
from datetime import datetime, timedelta
class UserAuth:
def __init__(self, db_path='users.db'):
self.conn = sqlite3.connect(db_path)
self.create_table()
# Dictionnaire pour stocker les tentatives d'authentification échouées
self.failed_attempts = {}
self.max_attempts = 5
self.lockout_time = 15 # minutes
def create_table(self):
cursor = self.conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE,
password_hash TEXT,
salt TEXT,
email TEXT,
last_login TIMESTAMP,
failed_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP
)
''')
self.conn.commit()
def validate_password_strength(self, password):
"""Vérifie que le mot de passe répond aux exigences de sécurité"""
if len(password) < 12:
return False, "Le mot de passe doit contenir au moins 12 caractères"
if not re.search(r'[A-Z]', password):
return False, "Le mot de passe doit contenir au moins une lettre majuscule"
if not re.search(r'[a-z]', password):
return False, "Le mot de passe doit contenir au moins une lettre minuscule"
if not re.search(r'\d', password):
return False, "Le mot de passe doit contenir au moins un chiffre"
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
return False, "Le mot de passe doit contenir au moins un caractère spécial"
return True, "Mot de passe valide"
def validate_email(self, email):
"""Vérifie que l'email est dans un format valide"""
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(email_pattern, email) is not None
def register_user(self, username, password, email):
"""Enregistre un nouvel utilisateur avec un hash sécurisé du mot de passe"""
# Validation des entrées
if not username or not password or not email:
return False, "Tous les champs sont obligatoires"
if not self.validate_email(email):
return False, "Format d'email invalide"
valid_password, message = self.validate_password_strength(password)
if not valid_password:
return False, message
try:
# Générer un sel aléatoire
salt = bcrypt.gensalt()
# Hash le mot de passe avec bcrypt (inclut automatiquement le sel)
hashed_password = bcrypt.hashpw(password.encode(), salt)
cursor = self.conn.cursor()
cursor.execute(
"INSERT INTO users (username, password_hash, salt, email) VALUES (?, ?, ?, ?)",
(username, hashed_password.decode(), salt.decode(), email)
)
self.conn.commit()
return True, "Utilisateur enregistré avec succès"
except sqlite3.IntegrityError:
return False, "Nom d'utilisateur déjà pris"
except Exception as e:
return False, f"Erreur lors de l'enregistrement : {str(e)}"
def is_account_locked(self, username):
"""Vérifie si le compte est verrouillé en raison de trop nombreuses tentatives d'authentification échouées"""
cursor = self.conn.cursor()
cursor.execute(
"SELECT failed_attempts, locked_until FROM users WHERE username = ?",
(username,)
)
result = cursor.fetchone()
if not result:
return False
failed_attempts, locked_until = result
if locked_until is None:
return False
locked_until_time = datetime.fromisoformat(locked_until)
if locked_until_time > datetime.now():
return True
return False
def authenticate_user(self, username, password):
"""Authentifie un utilisateur en vérifiant son mot de passe"""
# Vérifier si le compte est verrouillé
if self.is_account_locked(username):
return False, "Compte verrouillé temporairement en raison de trop nombreuses tentatives échouées"
# Rechercher l'utilisateur par son nom d'utilisateur
cursor = self.conn.cursor()
cursor.execute(
"SELECT id, password_hash, failed_attempts FROM users WHERE username = ?",
(username,)
)
user = cursor.fetchone()
# Simuler un délai constant même si l'utilisateur n'existe pas (pour éviter les attaques temporelles)
if not user:
# Temps de calcul simulé pour un hash bcrypt
time.sleep(0.1)
return False, "Nom d'utilisateur ou mot de passe incorrect"
user_id, stored_hash, failed_attempts = user
# Vérifier le mot de passe
try:
is_valid = bcrypt.checkpw(password.encode(), stored_hash.encode())
if is_valid:
# Réinitialiser les tentatives échouées et mettre à jour la dernière connexion
cursor.execute(
"UPDATE users SET failed_attempts = 0, last_login = ? WHERE id = ?",
(datetime.now().isoformat(), user_id)
)
self.conn.commit()
return True, "Authentification réussie"
else:
# Incrémenter le compteur de tentatives échouées
new_failed_attempts = failed_attempts + 1
# Verrouiller le compte si nécessaire
if new_failed_attempts >= self.max_attempts:
locked_until = (datetime.now() + timedelta(minutes=self.lockout_time)).isoformat()
cursor.execute(
"UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(new_failed_attempts, locked_until, user_id)
)
else:
cursor.execute(
"UPDATE users SET failed_attempts = ? WHERE id = ?",
(new_failed_attempts, user_id)
)
self.conn.commit()
return False, "Nom d'utilisateur ou mot de passe incorrect"
except Exception as e:
return False, f"Erreur lors de l'authentification : {str(e)}"
Améliorations apportées :
- Utilisation de bcrypt pour le hachage des mots de passe, qui inclut automatiquement un sel unique et est conçu pour être lent (résistant aux attaques par force brute)
- Validation de la robustesse des mots de passe
- Validation du format d'email
- Mécanisme de verrouillage des comptes après plusieurs tentatives échouées
- Gestion des erreurs améliorée avec des messages spécifiques
- Protection contre les attaques temporelles en simulant un délai constant
- Stockage des informations supplémentaires comme les tentatives échouées et la dernière connexion
Partie C : Authentification multi-facteurs
Voici une extension de la classe UserAuth pour implémenter l'authentification à deux facteurs avec TOTP :
import pyotp
import qrcode
from io import BytesIO
import base64
class UserAuthMFA(UserAuth):
def __init__(self, db_path='users.db'):
super().__init__(db_path)
self.setup_mfa_table()
def setup_mfa_table(self):
"""Crée la table pour stocker les informations MFA"""
cursor = self.conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS user_mfa (
user_id INTEGER PRIMARY KEY,
secret_key TEXT,
is_enabled BOOLEAN DEFAULT 0,
backup_codes TEXT,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
self.conn.commit()
def generate_totp_secret(self):
"""Génère une clé secrète pour TOTP"""
return pyotp.random_base32()
def setup_mfa(self, user_id, username):
"""Configure l'authentification à deux facteurs pour un utilisateur"""
# Générer une clé secrète
secret_key = self.generate_totp_secret()
# Générer des codes de secours (pour récupération)
backup_codes = []
for _ in range(8):
backup_codes.append(secrets.token_hex(4).upper())
# Stocker la clé secrète et les codes de secours
cursor = self.conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO user_mfa (user_id, secret_key, backup_codes) VALUES (?, ?, ?)",
(user_id, secret_key, ','.join(backup_codes))
)
self.conn.commit()
# Créer une URL pour le QR code
totp = pyotp.TOTP(secret_key)
provisioning_uri = totp.provisioning_uri(username, issuer_name="CryptoCourseMFA")
return {
'secret_key': secret_key,
'provisioning_uri': provisioning_uri,
'backup_codes': backup_codes
}
def generate_qr_code(self, provisioning_uri):
"""Génère un QR code à partir de l'URI de provisionnement"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convertir l'image en base64 pour l'affichage HTML
buffered = BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
def verify_totp(self, user_id, totp_code):
"""Vérifie le code TOTP fourni par l'utilisateur"""
cursor = self.conn.cursor()
cursor.execute(
"SELECT secret_key FROM user_mfa WHERE user_id = ?",
(user_id,)
)
result = cursor.fetchone()
if not result:
return False, "Configuration MFA non trouvée"
secret_key = result[0]
totp = pyotp.TOTP(secret_key)
# Vérifier le code avec une fenêtre de 30 secondes
if totp.verify(totp_code):
return True, "Code validé"
else:
return False, "Code invalide ou expiré"
def enable_mfa(self, user_id):
"""Active l'authentification à deux facteurs pour un utilisateur"""
cursor = self.conn.cursor()
cursor.execute(
"UPDATE user_mfa SET is_enabled = 1 WHERE user_id = ?",
(user_id,)
)
self.conn.commit()
return True
def disable_mfa(self, user_id):
"""Désactive l'authentification à deux facteurs pour un utilisateur"""
cursor = self.conn.cursor()
cursor.execute(
"UPDATE user_mfa SET is_enabled = 0 WHERE user_id = ?",
(user_id,)
)
self.conn.commit()
return True
def is_mfa_enabled(self, user_id):
"""Vérifie si l'authentification à deux facteurs est activée pour un utilisateur"""
cursor = self.conn.cursor()
cursor.execute(
"SELECT is_enabled FROM user_mfa WHERE user_id = ?",
(user_id,)
)
result = cursor.fetchone()
if not result:
return False
return result[0] == 1
def verify_backup_code(self, user_id, provided_code):
"""Vérifie un code de secours et le supprime s'il est valide"""
cursor = self.conn.cursor()
cursor.execute(
"SELECT backup_codes FROM user_mfa WHERE user_id = ?",
(user_id,)
)
result = cursor.fetchone()
if not result or not result[0]:
return False, "Aucun code de secours disponible"
backup_codes = result[0].split(',')
if provided_code in backup_codes:
# Supprimer le code utilisé
backup_codes.remove(provided_code)
# Mettre à jour les codes restants
cursor.execute(
"UPDATE user_mfa SET backup_codes = ? WHERE user_id = ?",
(','.join(backup_codes), user_id)
)
self.conn.commit()
return True, "Code de secours validé"
else:
return False, "Code de secours invalide"
def authenticate_user_with_mfa(self, username, password, totp_code=None, backup_code=None):
"""Authentifie un utilisateur avec mot de passe + MFA si activé"""
# Première étape : authentification par mot de passe
success, message = self.authenticate_user(username, password)
if not success:
return success, message
# Récupérer l'ID de l'utilisateur
cursor = self.conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
user_id = cursor.fetchone()[0]
# Vérifier si MFA est activé
if self.is_mfa_enabled(user_id):
# Si un code TOTP est fourni
if totp_code:
return self.verify_totp(user_id, totp_code)
# Si un code de secours est fourni
elif backup_code:
return self.verify_backup_code(user_id, backup_code)
# Si aucun code n'est fourni mais MFA est activé
else:
return False, "Authentification à deux facteurs requise"
# Si MFA n'est pas activé, l'authentification par mot de passe suffit
return success, message
Explication :
Cette implémentation d'authentification à deux facteurs utilise les bibliothèques suivantes :
pyotp
: Pour générer et vérifier les codes TOTP (Time-based One-Time Password)qrcode
: Pour générer les QR codes que les utilisateurs peuvent scanner avec des applications comme Google Authenticator ou Authy
Le processus d'implémentation de l'authentification à deux facteurs comprend :
- Configuration initiale :
- Génération d'une clé secrète unique pour chaque utilisateur
- Création d'un QR code contenant cette clé pour faciliter la configuration des applications d'authentification
- Génération de codes de secours pour permettre la récupération en cas de perte d'accès à l'appareil d'authentification
- Flux d'authentification :
- L'utilisateur s'authentifie d'abord avec son mot de passe
- Si 2FA est activé, l'utilisateur doit ensuite fournir un code TOTP valide ou un code de secours
- L'authentification n'est réussie que si les deux facteurs sont validés
- Gestion des codes de secours :
- Stockage sécurisé des codes de secours
- Suppression des codes utilisés pour éviter leur réutilisation
Intégration dans une application Web :
Pour intégrer cette fonctionnalité dans une application Web, vous auriez besoin de :
- Une page dédiée à la configuration 2FA où l'utilisateur peut scanner le QR code
- Une étape supplémentaire dans le processus de connexion pour demander le code TOTP
- Une option pour utiliser des codes de secours
- Des fonctionnalités pour activer/désactiver la 2FA dans les paramètres du compte
Cette implémentation offre un niveau de sécurité significativement plus élevé que l'authentification par mot de passe seul, car même si un attaquant obtient le mot de passe d'un utilisateur, il ne pourra pas se connecter sans accès à l'appareil d'authentification de l'utilisateur.
Exercice 5.2 : Infrastructure à clé publique (PKI)
Les infrastructures à clés publiques (PKI) sont essentielles pour l'établissement de la confiance dans les communications numériques. Dans cet exercice, vous allez créer et gérer une PKI simple.
Partie A : Création d'une autorité de certification (CA)
Écrivez un script Python qui crée une autorité de certification racine, en générant une paire de clés RSA et un certificat auto-signé. Le script doit permettre de spécifier les informations suivantes pour le certificat :
- Nom commun (CN)
- Organisation (O)
- Pays (C)
- Durée de validité
Partie B : Émission de certificats
Étendez votre script pour permettre à l'autorité de certification de générer et signer des certificats pour d'autres entités (par exemple, des serveurs ou des utilisateurs). Le script doit :
- Générer une paire de clés pour l'entité
- Créer une demande de signature de certificat (CSR)
- Signer la CSR avec la clé privée de la CA
- Enregistrer le certificat signé
Partie C : Révocation et vérification
Complétez votre script avec des fonctionnalités pour :
- Révoquer un certificat en ajoutant son numéro de série à une liste de révocation de certificats (CRL)
- Générer et publier la CRL
- Vérifier si un certificat est valide (non expiré et non révoqué)
- Vérifier la chaîne de confiance d'un certificat
Solution
Partie A : Création d'une autorité de certification (CA)
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
import datetime
import os
import uuid
class CertificateAuthority:
def __init__(self, storage_path='./pki'):
"""Initialise l'autorité de certification avec un chemin de stockage"""
self.storage_path = storage_path
self.ca_cert_path = os.path.join(storage_path, 'ca_cert.pem')
self.ca_key_path = os.path.join(storage_path, 'ca_key.pem')
self.crl_path = os.path.join(storage_path, 'crl.pem')
self.issued_certs_dir = os.path.join(storage_path, 'issued_certs')
self.revoked_certs = []
# Créer les répertoires nécessaires
os.makedirs(self.storage_path, exist_ok=True)
os.makedirs(self.issued_certs_dir, exist_ok=True)
def create_root_ca(self, common_name, organization, country, validity_days=3650):
"""Crée une autorité de certification racine avec les informations fournies"""
# Générer une paire de clés RSA pour la CA
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
# Définir les informations du sujet du certificat
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COUNTRY_NAME, country)
])
# Définir les contraintes de base (CA: True)
basic_constraints = x509.BasicConstraints(ca=True, path_length=None)
# Déterminer les dates de validité
now = datetime.datetime.utcnow()
validity_start = now
validity_end = now + datetime.timedelta(days=validity_days)
# Créer le certificat auto-signé
certificate = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
subject # Auto-signé, donc le sujet = émetteur
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
validity_start
).not_valid_after(
validity_end
).add_extension(
basic_constraints, critical=True
).add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False
), critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
critical=False
).sign(private_key, hashes.SHA256(), default_backend())
# Sérialiser et sauvegarder la clé privée (encodée avec mot de passe)
password = self._generate_password()
with open(self.ca_key_path, 'wb') as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(password)
))
# Sauvegarder le mot de passe dans un fichier séparé (dans un environnement de production,
# il faudrait utiliser un gestionnaire de secrets plus sécurisé)
with open(os.path.join(self.storage_path, 'ca_key_password.txt'), 'wb') as f:
f.write(password)
# Sauvegarder le certificat
with open(self.ca_cert_path, 'wb') as f:
f.write(certificate.public_bytes(serialization.Encoding.PEM))
print(f"CA root certificate created: {self.ca_cert_path}")
print(f"CA private key saved: {self.ca_key_path}")
return certificate, private_key
def _generate_password(self):
"""Génère un mot de passe aléatoire pour la protection de la clé privée"""
return os.urandom(32) # 256 bits
def load_ca_credentials(self):
"""Charge les informations d'identification de la CA à partir des fichiers"""
if not os.path.exists(self.ca_cert_path) or not os.path.exists(self.ca_key_path):
raise FileNotFoundError("CA certificate or private key not found. Run create_root_ca first.")
# Charger le certificat de la CA
with open(self.ca_cert_path, 'rb') as f:
ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend())
# Charger la clé privée de la CA
with open(os.path.join(self.storage_path, 'ca_key_password.txt'), 'rb') as f:
password = f.read()
with open(self.ca_key_path, 'rb') as f:
ca_key = serialization.load_pem_private_key(
f.read(),
password=password,
backend=default_backend()
)
return ca_cert, ca_key
# Exemple d'utilisation
if __name__ == "__main__":
ca = CertificateAuthority()
ca.create_root_ca(
common_name="Example Root CA",
organization="Example Organization",
country="FR",
validity_days=3650 # 10 ans
)
Ce code crée une autorité de certification racine avec les caractéristiques suivantes :
- Génération d'une paire de clés RSA 4096 bits
- Création d'un certificat auto-signé avec les informations fournies
- Inclusion des extensions appropriées (BasicConstraints, KeyUsage) pour un certificat de CA
- Sauvegarde sécurisée de la clé privée avec chiffrement
- Organisation des fichiers dans une structure de répertoires appropriée
Partie B : Émission de certificats
def issue_certificate(self, common_name, organization, country, is_ca=False,
validity_days=365, key_size=2048, san_dns_names=None, san_ip_addresses=None):
"""Émet un certificat pour une entité"""
# Charger les informations d'identification de la CA
ca_cert, ca_key = self.load_ca_credentials()
# Générer une paire de clés pour le certificat
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
# Créer un identifiant unique pour le certificat
cert_id = str(uuid.uuid4())
cert_dir = os.path.join(self.issued_certs_dir, cert_id)
os.makedirs(cert_dir, exist_ok=True)
# Définir les informations du sujet
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COUNTRY_NAME, country)
])
# Déterminer les dates de validité
now = datetime.datetime.utcnow()
validity_start = now
validity_end = now + datetime.timedelta(days=validity_days)
# Créer le certificat
cert_builder = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
ca_cert.subject # L'émetteur est la CA
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
validity_start
).not_valid_after(
validity_end
).add_extension(
x509.BasicConstraints(ca=is_ca, path_length=0 if is_ca else None),
critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(private_key.public_key()),
critical=False
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()),
critical=False
)
# Ajouter les extensions d'utilisation de la clé
if is_ca:
cert_builder = cert_builder.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False
),
critical=True
)
else:
cert_builder = cert_builder.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=True,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False
),
critical=True
).add_extension(
x509.ExtendedKeyUsage([
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH
]),
critical=False
)
# Ajouter les noms alternatifs du sujet si fournis
san_list = []
if san_dns_names:
for dns_name in san_dns_names:
san_list.append(x509.DNSName(dns_name))
if san_ip_addresses:
for ip in san_ip_addresses:
san_list.append(x509.IPAddress(ipaddress.ip_address(ip)))
if san_list:
cert_builder = cert_builder.add_extension(
x509.SubjectAlternativeName(san_list),
critical=False
)
# Signer le certificat avec la clé privée de la CA
certificate = cert_builder.sign(ca_key, hashes.SHA256(), default_backend())
# Sauvegarder le certificat
cert_path = os.path.join(cert_dir, 'cert.pem')
with open(cert_path, 'wb') as f:
f.write(certificate.public_bytes(serialization.Encoding.PEM))
# Sauvegarder la clé privée (en clair pour l'exemple, mais dans un environnement
# de production, il faudrait la protéger)
key_path = os.path.join(cert_dir, 'key.pem')
with open(key_path, 'wb') as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
# Sauvegarder les métadonnées du certificat
metadata = {
'id': cert_id,
'common_name': common_name,
'organization': organization,
'country': country,
'is_ca': is_ca,
'validity_start': validity_start.isoformat(),
'validity_end': validity_end.isoformat(),
'serial_number': certificate.serial_number,
'is_revoked': False
}
with open(os.path.join(cert_dir, 'metadata.json'), 'w') as f:
json.dump(metadata, f, indent=2)
print(f"Certificate issued: {cert_path}")
print(f"Private key saved: {key_path}")
return cert_id, certificate, private_key
def create_csr(self, common_name, organization, country, key_size=2048,
san_dns_names=None, san_ip_addresses=None):
"""Crée une demande de signature de certificat (CSR)"""
# Générer une paire de clés
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
# Définir les informations du sujet
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization),
x509.NameAttribute(NameOID.COUNTRY_NAME, country)
])
# Créer le builder pour la CSR
builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
# Ajouter l'extension d'utilisation de la clé
builder = builder.add_extension(
x509.KeyUsage(
digital_signature=True,
content_commitment=True,
key_encipherment=True,
data_encipherment=False,
key_agreement=False,
key_cert_sign=False,
crl_sign=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
# Ajouter les noms alternatifs du sujet si fournis
san_list = []
if san_dns_names:
for dns_name in san_dns_names:
san_list.append(x509.DNSName(dns_name))
if san_ip_addresses:
for ip in san_ip_addresses:
san_list.append(x509.IPAddress(ipaddress.ip_address(ip)))
if san_list:
builder = builder.add_extension(
x509.SubjectAlternativeName(san_list),
critical=False
)
# Signer la CSR avec la clé privée
csr = builder.sign(private_key, hashes.SHA256(), default_backend())
# Sauvegarder la CSR et la clé privée
csr_id = str(uuid.uuid4())
csr_dir = os.path.join(self.storage_path, 'csrs', csr_id)
os.makedirs(csr_dir, exist_ok=True)
csr_path = os.path.join(csr_dir, 'csr.pem')
with open(csr_path, 'wb') as f:
f.write(csr.public_bytes(serialization.Encoding.PEM))
key_path = os.path.join(csr_dir, 'key.pem')
with open(key_path, 'wb') as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
print(f"CSR created: {csr_path}")
print(f"Private key saved: {key_path}")
return csr_id, csr, private_key
def sign_csr(self, csr_path, is_ca=False, validity_days=365):
"""Signe une demande de signature de certificat (CSR)"""
# Charger la CSR
with open(csr_path, 'rb') as f:
csr = x509.load_pem_x509_csr(f.read(), default_backend())
# Vérifier la signature de la CSR
if not csr.is_signature_valid:
raise ValueError("CSR signature is invalid")
# Charger les informations d'identification de la CA
ca_cert, ca_key = self.load_ca_credentials()
# Créer un identifiant unique pour le certificat
cert_id = str(uuid.uuid4())
cert_dir = os.path.join(self.issued_certs_dir, cert_id)
os.makedirs(cert_dir, exist_ok=True)
# Déterminer les dates de validité
now = datetime.datetime.utcnow()
validity_start = now
validity_end = now + datetime.timedelta(days=validity_days)
# Créer le certificat à partir de la CSR
cert_builder = x509.CertificateBuilder().subject_name(
csr.subject
).issuer_name(
ca_cert.subject
).public_key(
csr.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
validity_start
).not_valid_after(
validity_end
).add_extension(
x509.BasicConstraints(ca=is_ca, path_length=0 if is_ca else None),
critical=True
).add_extension(
x509.SubjectKeyIdentifier.from_public_key(csr.public_key()),
critical=False
).add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()),
critical=False
)
# Copier les extensions de la CSR si appropriées
for extension in csr.extensions:
# Ne pas copier les extensions qu'on a déjà ajoutées
if extension.oid not in [x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER,
x509.oid.ExtensionOID.BASIC_CONSTRAINTS]:
cert_builder = cert_builder.add_extension(extension.value, extension.critical)
# Signer le certificat avec la clé privée de la CA
certificate = cert_builder.sign(ca_key, hashes.SHA256(), default_backend())
# Sauvegarder le certificat
cert_path = os.path.join(cert_dir, 'cert.pem')
with open(cert_path, 'wb') as f:
f.write(certificate.public_bytes(serialization.Encoding.PEM))
# Sauvegarder les métadonnées du certificat
metadata = {
'id': cert_id,
'common_name': csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'organization': csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value if csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) else None,
'country': csr.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value if csr.subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) else None,
'is_ca': is_ca,
'validity_start': validity_start.isoformat(),
'validity_end': validity_end.isoformat(),
'serial_number': certificate.serial_number,
'is_revoked': False
}
with open(os.path.join(cert_dir, 'metadata.json'), 'w') as f:
json.dump(metadata, f, indent=2)
print(f"Certificate issued from CSR: {cert_path}")
return cert_id, certificate
Ces méthodes permettent :
-
issue_certificate : Émettre directement un certificat pour une entité en générant une nouvelle paire de clés et en créant un certificat signé par la CA. Cette méthode prend en charge :
- La génération des clés
- La création d'un certificat avec les informations du sujet
- L'ajout des extensions appropriées selon le type de certificat (CA ou non)
- L'ajout optionnel de noms alternatifs (SAN) pour les certificats de serveur
-
create_csr : Créer une demande de signature de certificat, généralement utilisée lorsque l'entité génère sa propre paire de clés et souhaite obtenir un certificat signé par la CA.
-
sign_csr : Signer une CSR existante et générer un certificat. Cette méthode est utile pour les scénarios où l'entité soumettant la demande conserve sa clé privée et envoie uniquement la CSR à la CA.
Partie C : Révocation et vérification
def revoke_certificate(self, cert_id, reason="unspecified"):
"""Révoque un certificat en l'ajoutant à la liste de révocation"""
# Charger les métadonnées du certificat
metadata_path = os.path.join(self.issued_certs_dir, cert_id, 'metadata.json')
if not os.path.exists(metadata_path):
raise ValueError(f"Certificate with ID {cert_id} not found")
with open(metadata_path, 'r') as f:
metadata = json.load(f)
# Vérifier si le certificat est déjà révoqué
if metadata.get('is_revoked', False):
print(f"Certificate {cert_id} is already revoked")
return
# Charger le certificat
cert_path = os.path.join(self.issued_certs_dir, cert_id, 'cert.pem')
with open(cert_path, 'rb') as f:
certificate = x509.load_pem_x509_certificate(f.read(), default_backend())
# Ajouter le certificat à la liste des certificats révoqués
revocation_date = datetime.datetime.utcnow()
revoked_cert = x509.RevokedCertificateBuilder().serial_number(
certificate.serial_number
).revocation_date(
revocation_date
).build()
self.revoked_certs.append(revoked_cert)
# Mettre à jour les métadonnées du certificat
metadata['is_revoked'] = True
metadata['revocation_date'] = revocation_date.isoformat()
metadata['revocation_reason'] = reason
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"Certificate {cert_id} has been revoked")
# Générer une nouvelle CRL
self.generate_crl()
def generate_crl(self):
"""Génère une liste de révocation de certificats (CRL)"""
# Charger les informations d'identification de la CA
ca_cert, ca_key = self.load_ca_credentials()
# Créer le builder pour la CRL
builder = x509.CertificateRevocationListBuilder().issuer_name(
ca_cert.subject
).last_update(
datetime.datetime.utcnow()
).next_update(
datetime.datetime.utcnow() + datetime.timedelta(days=1) # Valide pour 1 jour
)
# Ajouter tous les certificats révoqués
for revoked_cert in self.revoked_certs:
builder = builder.add_revoked_certificate(revoked_cert)
# Signer la CRL avec la clé privée de la CA
crl = builder.sign(ca_key, hashes.SHA256(), default_backend())
# Sauvegarder la CRL
with open(self.crl_path, 'wb') as f:
f.write(crl.public_bytes(serialization.Encoding.PEM))
print(f"CRL generated: {self.crl_path}")
return crl
def load_crl(self):
"""Charge la liste de révocation de certificats (CRL) depuis le fichier"""
if not os.path.exists(self.crl_path):
print("No CRL found, generating a new one")
return self.generate_crl()
with open(self.crl_path, 'rb') as f:
crl = x509.load_pem_x509_crl(f.read(), default_backend())
# Charger les certificats révoqués dans la liste en mémoire
self.revoked_certs = list(crl)
return crl
def verify_certificate(self, cert_path):
"""Vérifie si un certificat est valide (non expiré et non révoqué)"""
# Charger le certificat à vérifier
with open(cert_path, 'rb') as f:
certificate = x509.load_pem_x509_certificate(f.read(), default_backend())
# Charger le certificat de la CA
with open(self.ca_cert_path, 'rb') as f:
ca_cert = x509.load_pem_x509_certificate(f.read(), default_backend())
# Charger la CRL
crl = self.load_crl()
# Vérifier la période de validité
now = datetime.datetime.utcnow()
if certificate.not_valid_before > now or certificate.not_valid_after < now:
return False, "Certificate is expired or not yet valid"
# Vérifier la signature du certificat
try:
certificate.verify_directly_issued_by(ca_cert)
except Exception as e:
return False, f"Certificate signature verification failed: {str(e)}"
# Vérifier si le certificat est révoqué
for revoked_cert in crl:
if revoked_cert.serial_number == certificate.serial_number:
return False, "Certificate has been revoked"
return True, "Certificate is valid"
def verify_certificate_chain(self, cert_path):
"""Vérifie la chaîne de confiance d'un certificat"""
# Charger le certificat à vérifier
with open(cert_path, 'rb') as f:
certificate = x509.load_pem_x509_certificate(f.read(), default_backend())
# Charger tous les certificats intermédiaires (dans un cas réel, ils seraient fournis)
intermediate_certs = []
# Charger le certificat racine
with open(self.ca_cert_path, 'rb') as f:
root_cert = x509.load_pem_x509_certificate(f.read(), default_backend())
# Vérifier que le certificat a été émis par un certificat de confiance
try:
# Si nous avons des certificats intermédiaires
if intermediate_certs:
# Vérifier que le certificat a été émis par un intermédiaire
issuer_found = False
for intermediate in intermediate_certs:
try:
certificate.verify_directly_issued_by(intermediate)
issuer_found = True
# Maintenant, vérifier que l'intermédiaire est de confiance
intermediate_valid, message = self.verify_certificate(intermediate)
if not intermediate_valid:
return False, f"Intermediate certificate is invalid: {message}"
break
except Exception:
continue
if not issuer_found:
return False, "No valid issuer found in the provided intermediates"
else:
# Vérifier directement par rapport au certificat racine
certificate.verify_directly_issued_by(root_cert)
# Vérifier si le certificat est valide (non expiré et non révoqué)
is_valid, message = self.verify_certificate(cert_path)
return is_valid, message
except Exception as e:
return False, f"Certificate chain verification failed: {str(e)}"
# Exemple d'utilisation pour la partie C
if __name__ == "__main__":
ca = CertificateAuthority()
# Exemple 1: Révoquer un certificat
cert_id = "abcd1234" # ID d'un certificat émis précédemment
ca.revoke_certificate(cert_id, reason="keyCompromise")
# Exemple 2: Vérifier un certificat
cert_path = "/path/to/cert.pem"
is_valid, message = ca.verify_certificate(cert_path)
print(f"Certificate validity: {is_valid}, {message}")
# Exemple 3: Vérifier une chaîne de certificats
is_chain_valid, chain_message = ca.verify_certificate_chain(cert_path)
print(f"Certificate chain validity: {is_chain_valid}, {chain_message}")
Cette partie complète l'implémentation d'une PKI en ajoutant des fonctionnalités pour :
-
Révoquer des certificats :
- Permet de marquer un certificat comme révoqué
- Stocke la date et la raison de la révocation
-
Générer une liste de révocation de certificats (CRL) :
- Crée un document signé contenant tous les certificats révoqués
- Inclut les numéros de série et les dates de révocation
- Définit une période de validité pour la CRL
-
Vérifier la validité d'un certificat :
- Vérifie que le certificat n'est pas expiré
- Vérifie que la signature du certificat est valide
- Vérifie que le certificat n'est pas révoqué (en consultant la CRL)
-
Vérifier une chaîne de certificats :
- Valide la chaîne de confiance depuis le certificat final jusqu'au certificat racine de confiance
- Gère les certificats intermédiaires si nécessaire
Notes importantes pour une utilisation en production :
-
Sécurité des clés privées :
- Dans un environnement de production, les clés privées devraient être stockées dans un module de sécurité matériel (HSM) ou un gestionnaire de secrets
- Les mots de passe ne devraient pas être stockés dans des fichiers texte
-
Distribution des CRL :
- Dans une PKI réelle, les CRL devraient être disponibles via un serveur HTTP ou LDAP
- Le certificat devrait inclure une extension avec l'URL de distribution de la CRL
-
OCSP (Online Certificate Status Protocol) :
- Pour une PKI moderne, OCSP serait préférable aux CRL pour les vérifications de révocation en temps réel
-
Hiérarchie de certificats :
- Une PKI de production utiliserait généralement une hiérarchie à plusieurs niveaux avec des CA intermédiaires
- La clé privée de la CA racine serait rarement utilisée et stockée hors ligne
Cette implémentation fournit une compréhension fondamentale des mécanismes sous-jacents d'une PKI, mais pour un déploiement en production, des outils comme OpenSSL, EJBCA ou Microsoft Active Directory Certificate Services seraient plus appropriés.
Exercice 5.3 : Authentification à deux facteurs
L'authentification à deux facteurs (2FA) est devenue un standard de sécurité pour protéger les comptes contre les compromissions de mot de passe. Dans cet exercice, vous allez implémenter un système TOTP (Time-based One-Time Password).
Partie A : Implémentation TOTP
Créez une classe Python qui implémente la génération et la vérification de codes TOTP selon la norme RFC 6238. La classe doit fournir les fonctionnalités suivantes :
- Génération d'une clé secrète aléatoire
- Génération d'un code TOTP à partir de la clé secrète
- Vérification d'un code TOTP fourni par l'utilisateur
- Génération d'une URI pour QR code compatible avec les applications d'authentification (Google Authenticator, Authy, etc.)
Partie B : Intégration avec le système d'authentification
Étendez le système d'authentification de l'exercice 5.1 pour intégrer l'authentification à deux facteurs en ajoutant :
- Un processus d'enregistrement 2FA pour les utilisateurs
- La possibilité d'activer/désactiver la 2FA pour un compte
- Une étape de vérification TOTP dans le processus de connexion
- Des codes de secours pour la récupération de compte
Partie C : Sécurité et expérience utilisateur
Réfléchissez aux compromis entre sécurité et expérience utilisateur dans l'authentification à deux facteurs. Répondez aux questions suivantes :
- Quels sont les avantages et les inconvénients des différentes méthodes de 2FA (TOTP, SMS, email, clés physiques) ?
- Comment gérer le cas où un utilisateur perd accès à son appareil d'authentification ?
- Quelles stratégies pourriez-vous mettre en place pour encourager l'adoption de la 2FA par les utilisateurs ?
Solution
Partie A : Implémentation TOTP
import pyotp
import qrcode
import io
import base64
import secrets
import time
import hashlib
class TOTPAuthenticator:
"""Classe pour la gestion de l'authentification TOTP (Time-based One-Time Password)"""
def __init__(self, digits=6, interval=30, algorithm='SHA1'):
"""
Initialise l'authentificateur TOTP
Args:
digits: Nombre de chiffres dans le code TOTP (généralement 6)
interval: Intervalle de temps en secondes pour la validité du code (généralement 30)
algorithm: Algorithme de hachage utilisé (SHA1, SHA256, SHA512)
"""
self.digits = digits
self.interval = interval
self.algorithm = algorithm
def generate_secret(self):
"""Génère une clé secrète aléatoire en base32"""
return pyotp.random_base32()
def create_totp(self, secret):
"""Crée un objet TOTP à partir d'une clé secrète"""
return pyotp.TOTP(
secret,
digits=self.digits,
interval=self.interval,
digest=getattr(hashlib, self.algorithm.lower())
)
def generate_code(self, secret):
"""Génère un code TOTP à partir d'une clé secrète"""
totp = self.create_totp(secret)
return totp.now()
def verify_code(self, secret, code, valid_window=1):
"""
Vérifie un code TOTP fourni par l'utilisateur
Args:
secret: Clé secrète de l'utilisateur
code: Code TOTP fourni par l'utilisateur
valid_window: Nombre d'intervalles avant/après l'heure actuelle à vérifier
(pour tenir compte des décalages d'horloge)
Returns:
bool: True si le code est valide, False sinon
"""
totp = self.create_totp(secret)
return totp.verify(code, valid_window=valid_window)
def generate_uri(self, secret, account_name, issuer_name):
"""
Génère une URI pour QR code compatible avec les applications d'authentification
Args:
secret: Clé secrète de l'utilisateur
account_name: Nom du compte (souvent l'email ou le nom d'utilisateur)
issuer_name: Nom de l'émetteur (souvent le nom de l'application ou du service)
Returns:
str: URI au format otpauth://
"""
totp = self.create_totp(secret)
return totp.provisioning_uri(
name=account_name,
issuer_name=issuer_name
)
def generate_qr_code(self, uri):
"""
Génère un QR code à partir d'une URI TOTP
Args:
uri: URI TOTP générée par generate_uri()
Returns:
str: QR code encodé en base64 pour affichage HTML
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convertir l'image en base64
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
def generate_backup_codes(self, count=8, length=8):
"""
Génère des codes de secours pour la récupération de compte
Args:
count: Nombre de codes à générer
length: Longueur de chaque code (en caractères)
Returns:
list: Liste des codes de secours
"""
backup_codes = []
for _ in range(count):
# Générer un code hexadécimal aléatoire et le formater en groupes
code = secrets.token_hex(length // 2).upper()
if length >= 8:
# Formater en groupes de 4 caractères pour une meilleure lisibilité
code = '-'.join([code[i:i+4] for i in range(0, len(code), 4)])
backup_codes.append(code)
return backup_codes
def verify_backup_code(self, provided_code, stored_codes):
"""
Vérifie un code de secours
Args:
provided_code: Code fourni par l'utilisateur
stored_codes: Liste des codes de secours valides
Returns:
tuple: (bool, list) - (Code valide?, Liste mise à jour des codes)
"""
# Normaliser le code fourni
normalized_code = provided_code.replace('-', '').upper()
# Vérifier le code
for i, code in enumerate(stored_codes):
normalized_stored = code.replace('-', '').upper()
if normalized_code == normalized_stored:
# Supprimer le code utilisé
updated_codes = stored_codes.copy()
updated_codes.pop(i)
return True, updated_codes
return False, stored_codes
# Exemple d'utilisation
if __name__ == "__main__":
# Créer un authentificateur TOTP
authenticator = TOTPAuthenticator()
# Générer une clé secrète
secret = authenticator.generate_secret()
print(f"Secret: {secret}")
# Générer une URI pour QR code
uri = authenticator.generate_uri(secret, "user@example.com", "MyApp")
print(f"URI: {uri}")
# Générer un code TOTP
code = authenticator.generate_code(secret)
print(f"Current code: {code}")
# Vérifier le code
is_valid = authenticator.verify_code(secret, code)
print(f"Code valid: {is_valid}")
# Générer des codes de secours
backup_codes = authenticator.generate_backup_codes()
print("Backup codes:")
for code in backup_codes:
print(f" {code}")
Cette implémentation TOTP offre les fonctionnalités suivantes :
-
Génération de clé secrète : Crée une clé secrète aléatoire en base32, compatible avec les applications d'authentification.
-
Génération et vérification de codes TOTP : Permet de générer et vérifier des codes temporaires basés sur l'heure actuelle et la clé secrète.
-
Création d'URI pour QR code : Génère une URI au format
otpauth://
que l'utilisateur peut scanner avec son application d'authentification. -
Génération de QR code : Convertit l'URI en image QR code, encodée en base64 pour l'affichage dans une page web.
-
Gestion des codes de secours : Permet de générer et vérifier des codes de secours pour la récupération de compte.
L'implémentation est conforme à la norme RFC 6238 (TOTP) et permet une personnalisation des paramètres comme le nombre de chiffres, l'intervalle de temps et l'algorithme de hachage.
Partie B : Intégration avec le système d'authentification
Cette partie a déjà été couverte en détail dans la solution de l'exercice 5.1 (Partie C), où nous avons créé une classe UserAuthMFA
qui étend le système d'authentification de base. Voici un résumé de son fonctionnement :
-
Structure de données : Une table
user_mfa
stocke la clé secrète, l'état d'activation et les codes de secours pour chaque utilisateur. -
Configuration de la 2FA : Le processus comprend la génération d'une clé secrète, la création d'un QR code et la génération de codes de secours.
-
Activation/désactivation : Des méthodes permettent d'activer ou de désactiver la 2FA pour un compte utilisateur.
-
Authentification : Le processus de connexion est étendu pour vérifier le code TOTP après la validation du mot de passe si la 2FA est activée.
-
Récupération : Les utilisateurs peuvent utiliser des codes de secours en cas de perte d'accès à leur dispositif d'authentification.
La classe UserAuthMFA
de l'exercice 5.1 pourrait être mise à jour pour utiliser la classe TOTPAuthenticator
que nous venons de créer, ce qui permettrait d'avoir une séparation plus claire des responsabilités.
Partie C : Sécurité et expérience utilisateur
1. Avantages et inconvénients des différentes méthodes de 2FA
Méthode | Avantages | Inconvénients |
---|---|---|
TOTP (applications) |
|
|
SMS |
|
|
|
|
|
Push notifications |
|
|
Clés physiques (FIDO U2F, WebAuthn) |
|
|
2. Gestion de la perte d'accès à l'appareil d'authentification
La perte d'accès au dispositif d'authentification est un problème majeur pour la 2FA. Voici plusieurs stratégies de récupération à mettre en place :
-
Codes de secours
- Générer plusieurs codes de secours à usage unique lors de l'activation de la 2FA
- Encourager les utilisateurs à les imprimer ou à les stocker dans un gestionnaire de mots de passe sécurisé
- Limiter le nombre d'utilisations des codes, et les invalider après usage
-
Méthodes alternatives
- Permettre plusieurs méthodes de 2FA (application + SMS + email)
- N'en exiger qu'une seule à la fois, mais permettre d'utiliser les autres comme secours
-
Appareils de confiance
- Permettre aux utilisateurs de marquer certains appareils comme "de confiance"
- Réduire la fréquence des vérifications 2FA sur ces appareils
- Utiliser ces appareils comme méthode de récupération
-
Processus de récupération avec vérification d'identité
- Exiger plusieurs formes de vérification d'identité (informations personnelles, historique du compte, etc.)
- Imposer un délai de récupération pour donner le temps de détecter une tentative frauduleuse
- Envoyer des notifications sur toutes les adresses email connues lors d'une tentative de récupération
-
Support client
- Former les équipes de support pour vérifier l'identité des utilisateurs de manière sécurisée
- Établir un processus clair de récupération avec plusieurs étapes de vérification
- Documenter toutes les demandes de récupération pour analyse ultérieure
3. Stratégies pour encourager l'adoption de la 2FA
Malgré ses avantages en termes de sécurité, la 2FA peut rencontrer une résistance de la part des utilisateurs en raison de sa complexité perçue. Voici des stratégies pour encourager son adoption :
-
Éducation et sensibilisation
- Expliquer clairement les risques liés à l'utilisation de mots de passe seuls
- Partager des histoires réelles de piratage de compte et comment la 2FA aurait pu les prévenir
- Créer des vidéos et tutoriels montrant la simplicité du processus
-
Expérience utilisateur optimisée
- Rendre le processus d'activation aussi simple que possible (QR code, instructions claires)
- Réduire les frictions dans le flux d'authentification (délai de confiance pour les appareils reconnus)
- Proposer plusieurs options de 2FA pour s'adapter aux préférences des utilisateurs
-
Incitations
- Offrir des avantages spécifiques aux utilisateurs qui activent la 2FA (espace de stockage supplémentaire, fonctionnalités exclusives)
- Gamifier l'expérience (badges de sécurité, scores de sécurité du compte)
- Petites récompenses (réductions, points de fidélité)
-
Stratégies progressives
- Rendre la 2FA obligatoire uniquement pour les actions sensibles (changements de mot de passe, paiements)
- Implémenter une introduction progressive (recommandé → fortement encouragé → obligatoire)
- Activer automatiquement pour les nouveaux comptes, avec possibilité de désactivation
-
Transparence et contrôle
- Permettre aux utilisateurs de voir l'historique des connexions à leur compte
- Envoyer des notifications lors des connexions depuis de nouveaux appareils/emplacements
- Donner aux utilisateurs un contrôle granulaire sur leurs paramètres de sécurité
-
Analyse et amélioration continue
- Suivre et analyser les taux d'adoption et d'abandon
- Recueillir les commentaires des utilisateurs sur les difficultés rencontrées
- Améliorer continuellement le processus en fonction des retours
En équilibrant sécurité et expérience utilisateur, et en communiquant clairement les avantages, il est possible d'atteindre des taux d'adoption élevés de la 2FA, améliorant ainsi significativement la sécurité globale du système.
Exercices du chapitre 5
Ressources complémentaires
- OWASP Authentication Cheat Sheet
- RFC 6238 - TOTP: Time-Based One-Time Password
- RFC 5280 - X.509 Public Key Infrastructure
- "Authentication: From Passwords to Public Keys" de Richard E. Smith
Conseils de réussite
Pour l'exercice 5.1, assurez-vous de comprendre les bonnes pratiques actuelles en matière de stockage de mots de passe. Les recommandations évoluent avec le temps à mesure que de nouvelles vulnérabilités sont découvertes.
Pour l'exercice 5.2, la gestion des certificats est un domaine complexe. N'hésitez pas à consulter des ressources supplémentaires sur les PKI pour approfondir votre compréhension.
Pour l'exercice 5.3, testez votre implémentation TOTP avec des applications réelles comme Google Authenticator pour vérifier sa compatibilité.