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

@@ -4,3 +4,13 @@ from django.apps import AppConfig
class StiftungConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "stiftung"
def ready(self):
# Configure admin site with 2FA support
try:
from django_otp.admin import OTPAdminSite
from django.contrib import admin
admin.site.__class__ = OTPAdminSite
except ImportError:
# django-otp not installed
pass

View File

@@ -1476,3 +1476,84 @@ class VierteljahresNachweisForm(forms.ModelForm):
)
return cleaned_data
# Two-Factor Authentication Forms
class TwoFactorSetupForm(forms.Form):
"""Form for setting up 2FA with TOTP verification"""
token = forms.CharField(
max_length=6,
min_length=6,
widget=forms.TextInput(attrs={
'class': 'form-control text-center',
'placeholder': '000000',
'autocomplete': 'off',
'pattern': '[0-9]{6}',
'inputmode': 'numeric'
}),
label='Bestätigungscode',
help_text='6-stelliger Code aus Ihrer Authenticator-App'
)
def clean_token(self):
token = self.cleaned_data.get('token')
if token and not token.isdigit():
raise ValidationError('Der Code darf nur Zahlen enthalten.')
return token
class TwoFactorVerifyForm(forms.Form):
"""Form for verifying 2FA during login"""
otp_token = forms.CharField(
max_length=8,
min_length=6,
widget=forms.TextInput(attrs={
'class': 'form-control form-control-lg text-center',
'placeholder': '000000',
'autocomplete': 'off',
'autofocus': True
}),
label='Authentifizierungscode',
help_text='6-stelliger Code aus der App oder 8-stelliger Backup-Code'
)
def clean_otp_token(self):
token = self.cleaned_data.get('otp_token')
if token:
token = token.strip().lower()
# Allow 6-digit TOTP codes or 8-character backup codes
if len(token) == 6 and token.isdigit():
return token
elif len(token) == 8 and re.match(r'^[a-f0-9]{8}$', token):
return token
else:
raise ValidationError(
'Bitte geben Sie einen 6-stelligen Authenticator-Code oder 8-stelligen Backup-Code ein.'
)
return token
class TwoFactorDisableForm(forms.Form):
"""Form for disabling 2FA with password confirmation"""
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'autocomplete': 'current-password',
'autofocus': True
}),
label='Passwort',
help_text='Geben Sie Ihr aktuelles Passwort zur Bestätigung ein'
)
class BackupTokenRegenerateForm(forms.Form):
"""Form for regenerating backup tokens"""
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': 'form-control',
'autocomplete': 'current-password'
}),
label='Passwort',
help_text='Geben Sie Ihr Passwort ein, um neue Backup-Codes zu generieren'
)

View File

@@ -8,7 +8,10 @@ import threading
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver
from django.http import HttpResponseRedirect
from django.shortcuts import reverse
from django.utils.deprecation import MiddlewareMixin
from django_otp.plugins.otp_totp.models import TOTPDevice
from stiftung.audit import get_client_ip, log_action, track_model_changes
@@ -36,6 +39,40 @@ class AuditMiddleware(MiddlewareMixin):
return response
class TwoFactorMiddleware(MiddlewareMixin):
"""
Middleware that enforces 2FA verification for users with 2FA enabled
"""
def process_request(self, request):
"""Check if user needs 2FA verification"""
# Skip if user is not authenticated
if not request.user.is_authenticated:
return None
# Skip for admin URLs and 2FA URLs themselves
if (request.path.startswith('/admin/') or
request.path.startswith('/two-factor/') or
request.path.startswith('/auth/2fa/') or
request.path == '/logout/' or
request.path.startswith('/static/') or
request.path.startswith('/media/')):
return None
# Check if user has 2FA enabled
has_2fa = TOTPDevice.objects.filter(user=request.user, confirmed=True).exists()
if has_2fa:
# Check if user has completed 2FA verification in this session
if not request.session.get('2fa_verified', False):
# Redirect to 2FA verification page
return HttpResponseRedirect(
reverse('stiftung:two_factor_verify') + f'?next={request.path}'
)
return None
def get_current_request():
"""Get the current request from thread-local storage"""
return getattr(_local, "request", None)

View File

@@ -331,6 +331,12 @@ urlpatterns = [
path(
"administration/users/<int:pk>/delete/", views.user_delete, name="user_delete"
),
# Two-Factor Authentication URLs
path("auth/2fa/setup/", views.two_factor_setup, name="two_factor_setup"),
path("auth/2fa/qr/", views.two_factor_qr, name="two_factor_qr"),
path("auth/2fa/verify/", views.two_factor_verify, name="two_factor_verify"),
path("auth/2fa/disable/", views.two_factor_disable, name="two_factor_disable"),
path("auth/2fa/backup-tokens/", views.backup_tokens, name="backup_tokens"),
# Hilfsbox URLs
path("help-box/edit/", views.edit_help_box, name="edit_help_box"),
path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"),

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)