Files
ETB/ETB-API/security/mfa/totp.py
Iliyan Angelov 6b247e5b9f Updates
2025-09-19 11:58:53 +03:00

236 lines
7.4 KiB
Python

"""
Time-based One-Time Password (TOTP) implementation for MFA
"""
import base64
import hashlib
import hmac
import time
import qrcode
import io
from typing import Optional, Tuple
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from ..models import MFADevice, AuditLog
User = get_user_model()
class TOTPGenerator:
"""TOTP generator and validator"""
def __init__(self, secret: str, time_step: int = 30, digits: int = 6):
self.secret = secret
self.time_step = time_step
self.digits = digits
def generate_token(self, timestamp: Optional[int] = None) -> str:
"""Generate TOTP token for given timestamp"""
if timestamp is None:
timestamp = int(time.time())
time_counter = timestamp // self.time_step
# Convert secret to bytes
secret_bytes = base64.b32decode(self.secret.upper() + '=' * (8 - len(self.secret) % 8))
# Generate HMAC-SHA1
time_bytes = time_counter.to_bytes(8, byteorder='big')
hmac_digest = hmac.new(secret_bytes, time_bytes, hashlib.sha1).digest()
# Extract dynamic binary code
offset = hmac_digest[-1] & 0x0f
code = int.from_bytes(hmac_digest[offset:offset+4], byteorder='big') & 0x7fffffff
# Convert to string with leading zeros
return str(code % (10 ** self.digits)).zfill(self.digits)
def verify_token(self, token: str, window: int = 1) -> bool:
"""Verify TOTP token with time window tolerance"""
current_time = int(time.time())
for i in range(-window, window + 1):
expected_token = self.generate_token(current_time + i * self.time_step)
if token == expected_token:
return True
return False
class MFAProvider:
"""MFA provider for managing TOTP devices"""
@staticmethod
def generate_secret() -> str:
"""Generate a new TOTP secret"""
import secrets
return base64.b32encode(secrets.token_bytes(20)).decode('ascii')
@staticmethod
def create_totp_device(user: User, device_name: str) -> Tuple[MFADevice, str]:
"""Create a new TOTP device for user"""
secret = MFAProvider.generate_secret()
device = MFADevice.objects.create(
user=user,
device_type='TOTP',
name=device_name,
secret_key=secret,
is_active=True
)
# Generate QR code data
qr_data = MFAProvider.generate_qr_code_data(user, secret)
return device, qr_data
@staticmethod
def generate_qr_code_data(user: User, secret: str) -> str:
"""Generate QR code data for TOTP setup"""
issuer = getattr(settings, 'MFA_ISSUER_NAME', 'ETB Incident Management')
account_name = f"{user.username}@{issuer}"
# TOTP URI format
uri = f"otpauth://totp/{account_name}?secret={secret}&issuer={issuer}"
return uri
@staticmethod
def generate_qr_code_image(qr_data: str) -> bytes:
"""Generate QR code image as bytes"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to bytes
img_buffer = io.BytesIO()
img.save(img_buffer, format='PNG')
img_buffer.seek(0)
return img_buffer.getvalue()
@staticmethod
def verify_totp_token(user: User, token: str, device_id: Optional[str] = None) -> bool:
"""Verify TOTP token for user"""
try:
# Get user's TOTP devices
devices = MFADevice.objects.filter(
user=user,
device_type='TOTP',
is_active=True
)
if device_id:
devices = devices.filter(id=device_id)
for device in devices:
totp = TOTPGenerator(device.secret_key)
if totp.verify_token(token):
# Update last used timestamp
device.last_used = timezone.now()
device.save(update_fields=['last_used'])
# Log successful MFA verification
AuditLog.objects.create(
user=user,
action_type='MFA_VERIFIED',
details={
'device_id': str(device.id),
'device_name': device.name,
'device_type': device.device_type
}
)
return True
# Log failed MFA attempt
AuditLog.objects.create(
user=user,
action_type='MFA_FAILED',
severity='MEDIUM',
details={
'device_id': device_id,
'token_provided': bool(token)
}
)
return False
except Exception as e:
# Log MFA error
AuditLog.objects.create(
user=user,
action_type='MFA_ERROR',
severity='HIGH',
details={'error': str(e)}
)
return False
@staticmethod
def enable_mfa_for_user(user: User) -> bool:
"""Enable MFA for user if they have at least one active device"""
active_devices = MFADevice.objects.filter(
user=user,
is_active=True
).count()
if active_devices > 0:
user.mfa_enabled = True
user.save(update_fields=['mfa_enabled'])
# Log MFA enablement
AuditLog.objects.create(
user=user,
action_type='MFA_ENABLED',
details={'device_count': active_devices}
)
return True
return False
@staticmethod
def disable_mfa_for_user(user: User) -> bool:
"""Disable MFA for user"""
user.mfa_enabled = False
user.save(update_fields=['mfa_enabled'])
# Deactivate all MFA devices
MFADevice.objects.filter(user=user).update(is_active=False)
# Log MFA disablement
AuditLog.objects.create(
user=user,
action_type='MFA_DISABLED',
details={'reason': 'user_requested'}
)
return True
@staticmethod
def get_user_mfa_devices(user: User) -> list:
"""Get all MFA devices for user"""
devices = MFADevice.objects.filter(user=user).order_by('-is_primary', 'name')
return [
{
'id': str(device.id),
'name': device.name,
'device_type': device.device_type,
'is_active': device.is_active,
'is_primary': device.is_primary,
'last_used': device.last_used,
'created_at': device.created_at
}
for device in devices
]