feat: Implement TOTP-based Two-Factor Authentication

- Add django-otp and qrcode dependencies
- Create comprehensive 2FA views and templates in German
- Add 2FA setup, verification, and management interfaces
- Implement backup token system with 10 recovery codes
- Add TwoFactorMiddleware for session enforcement
- Integrate 2FA controls into user navigation menu
- Support QR code generation for authenticator apps
- Add forms for secure 2FA operations with validation
- Configure OTP settings and admin site integration

Features:
- Optional 2FA (users can enable/disable)
- TOTP compatible with Google Authenticator, Authy, etc.
- Backup codes for emergency access
- German language interface
- Session-based 2FA enforcement
- Password confirmation for sensitive operations
- Production-ready with HTTPS support
This commit is contained in:
2025-09-30 00:10:02 +02:00
parent 92b689f5e7
commit ed6a02232e
29 changed files with 41444 additions and 1 deletions

View File

@@ -6,6 +6,8 @@ import time
from datetime import datetime, timedelta
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
@@ -14,10 +16,14 @@ from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import JsonResponse
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
@@ -7804,3 +7810,214 @@ def quarterly_confirmation_reset(request, pk):
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
# Two-Factor Authentication Views
@login_required
def two_factor_setup(request):
"""Setup or manage TOTP 2FA for the current user"""
# Check if user already has TOTP device
device = TOTPDevice.objects.filter(user=request.user, confirmed=True).first()
static_device = StaticDevice.objects.filter(user=request.user).first()
if device:
# User has 2FA enabled - show management options
context = {
'has_2fa': True,
'device': device,
'backup_token_count': static_device.token_set.count() if static_device else 0,
'title': 'Zwei-Faktor-Authentifizierung verwalten'
}
return render(request, 'stiftung/auth/two_factor_manage.html', context)
# User doesn't have 2FA - show setup
# Get or create unconfirmed TOTP device
device, created = TOTPDevice.objects.get_or_create(
user=request.user,
name='default',
defaults={'confirmed': False}
)
if request.method == "POST":
token = request.POST.get('token', '').strip()
if device.verify_token(token):
device.confirmed = True
device.save()
# Generate backup tokens
static_device = StaticDevice.objects.create(
user=request.user,
name='backup'
)
backup_tokens = []
for _ in range(10): # Generate 10 backup codes
token_value = random_hex()[:8] # 8 character backup codes
StaticToken.objects.create(
device=static_device,
token=token_value
)
backup_tokens.append(token_value)
messages.success(
request,
"Zwei-Faktor-Authentifizierung wurde erfolgreich aktiviert! "
"Bitte speichern Sie Ihre Backup-Codes sicher."
)
return render(request, 'stiftung/auth/backup_tokens.html', {
'backup_tokens': backup_tokens,
'title': 'Backup-Codes'
})
else:
messages.error(request, "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.")
# Generate QR code URL
qr_url = device.config_url
context = {
'device': device,
'qr_url': qr_url,
'title': 'Zwei-Faktor-Authentifizierung einrichten'
}
return render(request, 'stiftung/auth/two_factor_setup.html', context)
@login_required
def two_factor_qr(request):
"""Generate QR code for TOTP setup"""
device = TOTPDevice.objects.filter(user=request.user, confirmed=False).first()
if not device:
return HttpResponse("Kein Setup-Device gefunden", status=404)
# Generate QR code
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(device.config_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
response = HttpResponse(content_type="image/png")
img.save(response, "PNG")
return response
@login_required
def two_factor_verify(request):
"""Verify TOTP token during login process"""
if request.method == "POST":
token = request.POST.get('otp_token', '').strip()
# Check TOTP devices
devices = TOTPDevice.objects.filter(user=request.user, confirmed=True)
for device in devices:
if device.verify_token(token):
request.session['2fa_verified'] = True
messages.success(request, "Zwei-Faktor-Authentifizierung erfolgreich.")
return redirect(request.GET.get('next', 'stiftung:dashboard'))
# Check static backup tokens
static_devices = StaticDevice.objects.filter(user=request.user)
for device in static_devices:
if device.verify_token(token):
request.session['2fa_verified'] = True
messages.success(request, "Backup-Code erfolgreich verwendet.")
return redirect(request.GET.get('next', 'stiftung:dashboard'))
messages.error(request, "Ungültiger Code. Bitte versuchen Sie es erneut.")
context = {
'title': 'Zwei-Faktor-Authentifizierung',
'next': request.GET.get('next', '')
}
return render(request, 'stiftung/auth/two_factor_verify.html', context)
@login_required
def two_factor_disable(request):
"""Disable TOTP 2FA for the current user"""
if request.method == "POST":
password = request.POST.get('password', '')
if request.user.check_password(password):
# Remove all TOTP devices
TOTPDevice.objects.filter(user=request.user).delete()
# Remove all static backup token devices
StaticDevice.objects.filter(user=request.user).delete()
messages.success(
request,
"Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert."
)
return redirect("stiftung:dashboard")
else:
messages.error(request, "Ungültiges Passwort.")
context = {
'title': 'Zwei-Faktor-Authentifizierung deaktivieren'
}
return render(request, 'stiftung/auth/two_factor_disable.html', context)
@login_required
def backup_tokens(request):
"""Display or regenerate backup tokens"""
static_device = StaticDevice.objects.filter(user=request.user).first()
if request.method == "POST" and 'regenerate' in request.POST:
password = request.POST.get('password', '')
if request.user.check_password(password):
# Delete old tokens
if static_device:
static_device.delete()
# Generate new backup tokens
static_device = StaticDevice.objects.create(
user=request.user,
name='backup'
)
backup_tokens = []
for _ in range(10): # Generate 10 backup codes
token_value = random_hex()[:8] # 8 character backup codes
StaticToken.objects.create(
device=static_device,
token=token_value
)
backup_tokens.append(token_value)
messages.success(
request,
"Neue Backup-Codes wurden generiert. Bitte speichern Sie diese sicher."
)
context = {
'backup_tokens': backup_tokens,
'title': 'Neue Backup-Codes'
}
return render(request, 'stiftung/auth/backup_tokens.html', context)
else:
messages.error(request, "Ungültiges Passwort.")
# Show existing tokens (count only for security)
token_count = 0
if static_device:
token_count = static_device.token_set.count()
context = {
'token_count': token_count,
'has_tokens': token_count > 0,
'title': 'Backup-Codes'
}
return render(request, 'stiftung/auth/backup_tokens_manage.html', context)