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

@@ -34,6 +34,9 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.humanize",
"rest_framework",
"django_otp",
"django_otp.plugins.otp_totp",
"django_otp.plugins.otp_static",
"stiftung",
]
# Add this to app/core/settings.py
@@ -46,6 +49,8 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"stiftung.middleware.TwoFactorMiddleware", # 2FA enforcement middleware
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"stiftung.middleware.AuditMiddleware", # Audit logging middleware
@@ -134,3 +139,14 @@ if not DEBUG:
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# =============================================================================
# TWO-FACTOR AUTHENTICATION SETTINGS
# =============================================================================
# django-otp settings
OTP_TOTP_ISSUER = 'Stiftung Management System'
OTP_LOGIN_URL = '/two-factor/login/'
# Optional: Hide sensitive data in admin when not verified
OTP_ADMIN_HIDE_SENSITIVE_DATA = True

View File

@@ -16,6 +16,7 @@ urlpatterns = [
name="login",
),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
]
if settings.DEBUG:

View File

@@ -0,0 +1,22 @@
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
1 Vorname Nachname E-Mail Telefon IBAN Straße PLZ Ort Personentyp Geburtsdatum Pachtnummer Pachtbeginn_Erste Pachtende_Letzte Pachtzins_Aktuell Landwirtschaftliche_Ausbildung Berufserfahrung_Jahre Spezialisierung Notizen Aktiv
2 N/A Groiner Milch KG Groiner Allee 18 46459 Rees Gesellschaft WAHR
3 N/A M u D Becker GbR Helweg 76 46348 Raesfeld Gesellschaft WAHR
4 Walter Buchmann Büskes Heide 11 46499 Hamminkeln-Brünen Herrn WAHR
5 Andreas Elbers Grietherbusch Nr. 15 46459 Rees Herrn WAHR
6 N/A Fischer GbR Werricher Strasse 1 46487 Wesel Gesellschaft WAHR
7 Michael Ingenbleek Achterhoeker Schulweg 39 a 47626 Kevelaer Herrn WAHR
8 N/A Bröcheler KG Kervenheimer Str. 30 47626 Kevelaer Gesellschaft WAHR
9 Jens Bodden Moelscher Weg 16 47574 Goch Herrn WAHR
10 Marcel Müller Weysche Strasse 55 47546 Kalkar-Hanselaer Herrn WAHR
11 Ludger Paeßens Bergerfurther Strasse 4 46499 Hamminkeln Herrn WAHR
12 N/A Sander GbR Hardtbergweg 21 46569 Hünxe Gesellschaft WAHR
13 Dirk Schmäh Schlümersweg 7 A 46499 Hamminkeln-Brünen Herrn WAHR
14 Wolfgang Schmitz Höst-Vornicker-Weg 1 47652 Weeze Herrn WAHR
15 Sebastian Scholten Underbergsheide 46 46485 Wesel-Obrighoven Herrn WAHR
16 N/A Ulmenhorst GbR Schlootweg 10 46499 Hamminkeln Gesellschaft WAHR
17 Simone Gerten Obrighovener Str. 107 46485 Wesel Frau WAHR
18 Günther Engelmann Loher Weg 42 46484 Wesel Herrn WAHR
19 N/A Lühlshof KG Auf dem Heidchen 27 46485 Wesel Gesellschaft WAHR
20 Walter Schmidt Ilserheider Str. 4 32469 Petershagen Herrn WAHR
21 N/A Inge und Tom Rose GbR Kohlbreite 22 34414 Warburg-Calenberg Gesellschaft WAHR
22 N/A Stiftung Schwarzensteiner Weg 75 46569 Hünxe WAHR

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -9,3 +9,5 @@ requests==2.32.3
gunicorn==22.0.0
python-dateutil==2.9.0
markdown==3.6
django-otp==1.2.4
qrcode[pil]==7.4.2

View File

@@ -0,0 +1,11 @@
# Static Files Directory
This directory contains static files for the Django application.
## Structure:
- `css/` - Custom CSS files
- `js/` - Custom JavaScript files
- `img/` - Image assets
- `fonts/` - Font files
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.

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)

View File

@@ -622,6 +622,12 @@
<li><a class="dropdown-item" href="{% url 'stiftung:user_detail' user.pk %}">
<i class="fas fa-user me-2"></i>Mein Profil
</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Sicherheit</h6></li>
<li><a class="dropdown-item" href="{% url 'stiftung:two_factor_setup' %}">
<i class="fas fa-shield-alt me-2"></i>2FA verwalten
</a></li>
<li><hr class="dropdown-divider"></li>
{% if perms.stiftung.manage_users %}
<li><a class="dropdown-item" href="{% url 'stiftung:user_management' %}">
<i class="fas fa-users me-2"></i>Benutzerverwaltung

View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-key text-success"></i>
Backup-Codes für Zwei-Faktor-Authentifizierung
</h4>
</div>
<div class="card-body">
<div class="alert alert-success">
<h5><i class="fas fa-check-circle"></i> Zwei-Faktor-Authentifizierung aktiviert!</h5>
<p class="mb-0">
Ihr Konto ist jetzt mit Zwei-Faktor-Authentifizierung geschützt.
</p>
</div>
<div class="alert alert-warning">
<h6><i class="fas fa-exclamation-triangle"></i> Wichtig: Backup-Codes sicher speichern</h6>
<p class="mb-0">
Diese Backup-Codes können Sie verwenden, falls Sie keinen Zugriff auf Ihre
Authenticator-App haben. <strong>Speichern Sie diese Codes an einem sicheren Ort!</strong>
</p>
</div>
<div class="row">
<div class="col-md-6">
<h5>Ihre Backup-Codes:</h5>
<div class="bg-light p-3 rounded mb-3" id="backup-codes">
{% for token in backup_tokens %}
<div class="font-monospace fw-bold mb-2">{{ token }}</div>
{% endfor %}
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" onclick="copyBackupCodes()">
<i class="fas fa-copy"></i>
Codes kopieren
</button>
<button type="button" class="btn btn-outline-secondary" onclick="printBackupCodes()">
<i class="fas fa-print"></i>
Codes drucken
</button>
</div>
</div>
<div class="col-md-6">
<h5>Verwendung der Backup-Codes:</h5>
<ul class="text-muted">
<li>Jeder Code kann nur <strong>einmal</strong> verwendet werden</li>
<li>Geben Sie den Code anstelle des Authenticator-Codes ein</li>
<li>Codes sind 8 Zeichen lang (Buchstaben und Zahlen)</li>
<li>Bewahren Sie die Codes sicher und vertraulich auf</li>
</ul>
<div class="alert alert-info small">
<h6><i class="fas fa-lightbulb"></i> Empfohlene Aufbewahrung:</h6>
<ul class="mb-0 small">
<li>In einem Passwort-Manager</li>
<li>Ausgedruckt in einem Tresor</li>
<li>Verschlüsselt auf einem sicheren Gerät</li>
</ul>
</div>
</div>
</div>
<div class="text-center mt-4">
<a href="{% url 'stiftung:dashboard' %}" class="btn btn-primary btn-lg">
<i class="fas fa-home"></i>
Weiter zum Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function copyBackupCodes() {
const codes = [
{% for token in backup_tokens %}'{{ token }}'{% if not forloop.last %},{% endif %}{% endfor %}
];
const text = codes.join('\n');
navigator.clipboard.writeText(text).then(function() {
// Show success message
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check text-success"></i> Kopiert!';
btn.classList.add('btn-success');
btn.classList.remove('btn-outline-primary');
setTimeout(function() {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-primary');
}, 2000);
}).catch(function(err) {
alert('Fehler beim Kopieren: ' + err);
});
}
function printBackupCodes() {
const codes = [
{% for token in backup_tokens %}'{{ token }}'{% if not forloop.last %},{% endif %}{% endfor %}
];
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>Backup-Codes - Stiftung Management System</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #333; }
.codes { background: #f8f9fa; padding: 20px; border-radius: 5px; }
.code { font-family: monospace; font-size: 14px; margin: 10px 0; font-weight: bold; }
.warning { color: #d63384; font-weight: bold; margin: 20px 0; }
.date { color: #666; font-size: 12px; }
</style>
</head>
<body>
<h1>Backup-Codes für Zwei-Faktor-Authentifizierung</h1>
<p><strong>Konto:</strong> ${window.location.hostname}</p>
<p class="date"><strong>Generiert am:</strong> ${new Date().toLocaleString('de-DE')}</p>
<div class="warning">
⚠️ WICHTIG: Diese Codes sind vertraulich und können nur einmal verwendet werden!
</div>
<div class="codes">
${codes.map(code => '<div class="code">' + code + '</div>').join('')}
</div>
<p><strong>Verwendung:</strong></p>
<ul>
<li>Verwenden Sie diese Codes, falls Sie keinen Zugriff auf Ihre Authenticator-App haben</li>
<li>Geben Sie einen Code anstelle des Authenticator-Codes ein</li>
<li>Jeder Code kann nur einmal verwendet werden</li>
<li>Bewahren Sie diese Codes sicher auf</li>
</ul>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-key text-primary"></i>
Backup-Codes verwalten
</h4>
</div>
<div class="card-body">
{% if has_tokens %}
<div class="alert alert-info">
<h6><i class="fas fa-info-circle"></i> Backup-Codes Status</h6>
<p class="mb-0">
Sie haben derzeit <strong>{{ token_count }} Backup-Codes</strong> verfügbar.
Aus Sicherheitsgründen werden die Codes nicht angezeigt.
</p>
</div>
<div class="row">
<div class="col-md-6">
<h5>Neue Backup-Codes generieren</h5>
<p class="text-muted">
Wenn Sie neue Backup-Codes benötigen (z.B. weil Sie alle verbraucht haben
oder sie verloren haben), können Sie neue generieren.
</p>
<div class="alert alert-warning small">
<strong>Warnung:</strong> Das Generieren neuer Codes macht alle
bisherigen Backup-Codes ungültig.
</div>
</div>
<div class="col-md-6">
<h5>Passwort eingeben</h5>
<p class="text-muted">
Geben Sie Ihr Passwort ein, um neue Backup-Codes zu generieren:
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="regenerate" value="1">
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
autocomplete="current-password">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-warning">
<i class="fas fa-sync-alt"></i>
Neue Backup-Codes generieren
</button>
</div>
</form>
</div>
</div>
{% else %}
<div class="alert alert-warning">
<h6><i class="fas fa-exclamation-triangle"></i> Keine Backup-Codes vorhanden</h6>
<p class="mb-0">
Sie haben derzeit keine Backup-Codes. Das kann passieren, wenn:
</p>
<ul class="mb-0 mt-2">
<li>Sie alle Codes bereits verwendet haben</li>
<li>Die Zwei-Faktor-Authentifizierung neu eingerichtet wurde</li>
<li>Die Codes aus anderen Gründen gelöscht wurden</li>
</ul>
</div>
<div class="row">
<div class="col-md-6">
<h5>Neue Backup-Codes generieren</h5>
<p class="text-muted">
Generieren Sie neue Backup-Codes, um sicherzustellen, dass Sie
auch ohne Authenticator-App auf Ihr Konto zugreifen können.
</p>
</div>
<div class="col-md-6">
<h5>Passwort eingeben</h5>
<form method="post">
{% csrf_token %}
<input type="hidden" name="regenerate" value="1">
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
autocomplete="current-password">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i>
Backup-Codes generieren
</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row mt-4">
<div class="col-12">
<div class="alert alert-info">
<h6><i class="fas fa-lightbulb"></i> Über Backup-Codes:</h6>
<ul class="mb-0 small">
<li><strong>Zweck:</strong> Zugriff auf Ihr Konto, wenn die Authenticator-App nicht verfügbar ist</li>
<li><strong>Anzahl:</strong> 10 Codes werden generiert</li>
<li><strong>Verwendung:</strong> Jeder Code kann nur einmal verwendet werden</li>
<li><strong>Format:</strong> 8 Zeichen (Buchstaben und Zahlen)</li>
<li><strong>Sicherheit:</strong> Codes sollten sicher aufbewahrt werden</li>
</ul>
</div>
</div>
</div>
<div class="text-center mt-3">
<a href="{% url 'stiftung:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
Zurück zum Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-shield-alt text-warning"></i>
Zwei-Faktor-Authentifizierung deaktivieren
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<h6><i class="fas fa-exclamation-triangle"></i> Warnung</h6>
<p class="mb-0">
Sie sind dabei, die Zwei-Faktor-Authentifizierung zu deaktivieren.
Dies verringert die Sicherheit Ihres Kontos erheblich.
</p>
</div>
<h5>Was wird deaktiviert:</h5>
<ul class="text-muted mb-4">
<li>Authenticator-App Codes</li>
<li>Alle bestehenden Backup-Codes</li>
<li>Zusätzliche Sicherheitsebene beim Login</li>
</ul>
<h5>Bestätigung erforderlich</h5>
<p class="text-muted">
Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung
zu deaktivieren:
</p>
<form method="post">
{% csrf_token %}
<div class="mb-4">
<label for="password" class="form-label">Passwort</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
autocomplete="current-password"
autofocus>
<div class="form-text">
Ihr aktuelles Konto-Passwort
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-warning btn-lg">
<i class="fas fa-times-circle"></i>
Zwei-Faktor-Authentifizierung deaktivieren
</button>
<a href="{% url 'stiftung:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
Abbrechen
</a>
</div>
</form>
<div class="alert alert-info mt-4">
<h6><i class="fas fa-lightbulb"></i> Alternative Empfehlung</h6>
<p class="mb-0 small">
Statt die 2FA zu deaktivieren, können Sie auch:
</p>
<ul class="mb-0 small mt-1">
<li>Neue Backup-Codes generieren, falls Sie den Zugang verloren haben</li>
<li>Die 2FA neu einrichten, falls Probleme mit der App bestehen</li>
<li>Den Administrator kontaktieren, wenn Sie Hilfe benötigen</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-shield-alt text-success"></i>
Zwei-Faktor-Authentifizierung verwalten
</h4>
</div>
<div class="card-body">
<div class="alert alert-success">
<h6><i class="fas fa-check-circle"></i> 2FA ist aktiviert</h6>
<p class="mb-0">
Ihr Konto ist mit Zwei-Faktor-Authentifizierung geschützt.
Sie benötigen bei jeder Anmeldung einen Code aus Ihrer Authenticator-App.
</p>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-key"></i>
Backup-Codes
</h6>
</div>
<div class="card-body">
<p class="text-muted small">
Backup-Codes ermöglichen den Zugriff auf Ihr Konto,
wenn Ihre Authenticator-App nicht verfügbar ist.
</p>
{% if backup_token_count > 0 %}
<p class="mb-2">
<strong>{{ backup_token_count }} Backup-Codes</strong> verfügbar
</p>
<div class="d-grid">
<a href="{% url 'stiftung:backup_tokens' %}" class="btn btn-outline-primary">
<i class="fas fa-eye"></i>
Codes verwalten
</a>
</div>
{% else %}
<div class="alert alert-warning small">
<strong>Keine Backup-Codes vorhanden!</strong><br>
Es wird empfohlen, Backup-Codes zu generieren.
</div>
<div class="d-grid">
<a href="{% url 'stiftung:backup_tokens' %}" class="btn btn-warning">
<i class="fas fa-plus"></i>
Backup-Codes generieren
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-cog"></i>
Einstellungen
</h6>
</div>
<div class="card-body">
<p class="text-muted small">
Verwalten Sie Ihre 2FA-Einstellungen oder
deaktivieren Sie die Zwei-Faktor-Authentifizierung.
</p>
<div class="d-grid gap-2">
<a href="{% url 'stiftung:two_factor_disable' %}" class="btn btn-outline-danger">
<i class="fas fa-times-circle"></i>
2FA deaktivieren
</a>
</div>
<div class="mt-3">
<small class="text-muted">
<strong>Gerät eingerichtet:</strong><br>
Standard TOTP-Device
</small>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="alert alert-info">
<h6><i class="fas fa-lightbulb"></i> Tipps für 2FA:</h6>
<ul class="mb-0 small">
<li><strong>Authenticator-Apps:</strong> Google Authenticator, Microsoft Authenticator, Authy</li>
<li><strong>Backup-Codes:</strong> Bewahren Sie diese sicher auf (z.B. Passwort-Manager)</li>
<li><strong>Sicherheit:</strong> 2FA schützt auch bei kompromittierten Passwörtern</li>
<li><strong>Neues Gerät:</strong> Bei Gerätewechsel 2FA deaktivieren und neu einrichten</li>
</ul>
</div>
</div>
</div>
<div class="text-center mt-3">
<a href="{% url 'stiftung:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i>
Zurück zum Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,129 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-shield-alt text-primary"></i>
Zwei-Faktor-Authentifizierung einrichten
</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5>Schritt 1: Authenticator App installieren</h5>
<p class="text-muted">
Installieren Sie eine Authenticator-App auf Ihrem Smartphone:
</p>
<ul class="mb-4">
<li><strong>Google Authenticator</strong> (iOS/Android)</li>
<li><strong>Microsoft Authenticator</strong> (iOS/Android)</li>
<li><strong>Authy</strong> (iOS/Android/Desktop)</li>
<li><strong>1Password</strong> (Premium)</li>
</ul>
<h5>Schritt 2: QR-Code scannen</h5>
<p class="text-muted">
Scannen Sie den QR-Code mit Ihrer Authenticator-App:
</p>
<div class="text-center mb-4">
<img src="{% url 'stiftung:two_factor_qr' %}"
alt="QR Code für 2FA Setup"
class="img-fluid border rounded"
style="max-width: 200px;">
</div>
<details class="mb-4">
<summary class="text-muted small">Manueller Setup-Code anzeigen</summary>
<div class="mt-2 p-2 bg-light rounded">
<small class="font-monospace">{{ device.key }}</small>
</div>
<small class="text-muted d-block mt-1">
Falls der QR-Code nicht funktioniert, können Sie diesen Code manuell eingeben.
</small>
</details>
</div>
<div class="col-md-6">
<h5>Schritt 3: Bestätigen</h5>
<p class="text-muted">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:
</p>
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="token" class="form-label">Bestätigungscode</label>
<input type="text"
class="form-control"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
required
autocomplete="off">
<div class="form-text">
Der Code wechselt alle 30 Sekunden.
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-check"></i>
Zwei-Faktor-Authentifizierung aktivieren
</button>
<a href="{% url 'stiftung:dashboard' %}" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
Abbrechen
</a>
</div>
</form>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="alert alert-info">
<h6><i class="fas fa-info-circle"></i> Wichtige Hinweise:</h6>
<ul class="mb-0 small">
<li>Nach der Aktivierung erhalten Sie Backup-Codes für den Notfall</li>
<li>Bewahren Sie diese Backup-Codes sicher auf</li>
<li>Sie benötigen bei jeder Anmeldung einen Code aus der App</li>
<li>Die Zwei-Faktor-Authentifizierung erhöht die Sicherheit Ihres Kontos erheblich</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-focus on token input
const tokenInput = document.getElementById('token');
if (tokenInput) {
tokenInput.focus();
// Auto-submit when 6 digits entered
tokenInput.addEventListener('input', function() {
if (this.value.length === 6 && /^\d{6}$/.test(this.value)) {
// Small delay to allow user to see complete input
setTimeout(() => {
this.closest('form').submit();
}, 300);
}
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow">
<div class="card-header text-center">
<h4 class="mb-0">
<i class="fas fa-shield-alt text-primary"></i>
Zwei-Faktor-Authentifizierung
</h4>
</div>
<div class="card-body">
<p class="text-center text-muted mb-4">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
oder verwenden Sie einen Backup-Code.
</p>
<form method="post">
{% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<div class="mb-4">
<label for="otp_token" class="form-label">Authentifizierungscode</label>
<input type="text"
class="form-control form-control-lg text-center"
id="otp_token"
name="otp_token"
placeholder="000000"
maxlength="8"
required
autocomplete="off"
autofocus>
<div class="form-text text-center">
6-stelliger Code aus der App oder 8-stelliger Backup-Code
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-sign-in-alt"></i>
Bestätigen
</button>
</div>
</form>
<div class="text-center mt-4">
<details>
<summary class="text-muted small">Probleme beim Anmelden?</summary>
<div class="mt-2 small text-muted">
<p>Falls Sie keinen Zugriff auf Ihre Authenticator-App haben:</p>
<ul class="text-start">
<li>Verwenden Sie einen der 8-stelligen Backup-Codes</li>
<li>Kontaktieren Sie den Administrator</li>
</ul>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tokenInput = document.getElementById('otp_token');
if (tokenInput) {
// Auto-submit when 6 digits entered (TOTP) or 8 characters (backup code)
tokenInput.addEventListener('input', function() {
const value = this.value.trim();
if ((value.length === 6 && /^\d{6}$/.test(value)) ||
(value.length === 8 && /^[a-f0-9]{8}$/i.test(value))) {
// Small delay to allow user to see complete input
setTimeout(() => {
this.closest('form').submit();
}, 300);
}
});
}
});
</script>
{% endblock %}