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:
@@ -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