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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user