feat: Add native Django ALTCHA challenge generation and server-side HMAC validation endpoint

This commit is contained in:
Richard
2026-07-31 11:42:07 +02:00
parent 83c782c469
commit b7a5158254
7 changed files with 63 additions and 5 deletions
Binary file not shown.
+2 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
SkillViewSet, EventViewSet, TaskAreaViewSet, SkillViewSet, EventViewSet, TaskAreaViewSet,
ShiftViewSet, ShiftSignupView, EventTemplateViewSet ShiftViewSet, ShiftSignupView, EventTemplateViewSet, AltchaChallengeView
) )
router = DefaultRouter() router = DefaultRouter()
@@ -13,6 +13,7 @@ router.register('shifts', ShiftViewSet, basename='shift')
router.register('templates', EventTemplateViewSet, basename='template') router.register('templates', EventTemplateViewSet, basename='template')
urlpatterns = [ urlpatterns = [
path('altcha-challenge/', AltchaChallengeView.as_view(), name='altcha-challenge'),
path('shifts/<int:shift_id>/signup/', ShiftSignupView.as_view(), name='shift-signup'), path('shifts/<int:shift_id>/signup/', ShiftSignupView.as_view(), name='shift-signup'),
path('shifts/<int:shift_id>/signup/<int:signup_id>/', ShiftSignupView.as_view(), name='shift-signup-detail'), path('shifts/<int:shift_id>/signup/<int:signup_id>/', ShiftSignupView.as_view(), name='shift-signup-detail'),
path('', include(router.urls)), path('', include(router.urls)),
+58 -1
View File
@@ -14,12 +14,66 @@ from reportlab.lib.pagesizes import letter, landscape
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
import hashlib
import hmac
import random
import secrets
import base64
from django.conf import settings
from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate from .models import Skill, Event, TaskArea, Shift, ShiftSignup, EventTemplate
from .serializers import ( from .serializers import (
SkillSerializer, EventSerializer, TaskAreaSerializer, SkillSerializer, EventSerializer, TaskAreaSerializer,
ShiftSerializer, EventTemplateSerializer ShiftSerializer, EventTemplateSerializer
) )
class AltchaChallengeView(views.APIView):
permission_classes = [permissions.AllowAny]
def get(self, request):
salt = secrets.token_hex(12)
secret_number = random.randint(1000, 50000)
msg = f"{salt}{secret_number}".encode('utf-8')
key = settings.SECRET_KEY.encode('utf-8')
signature = hmac.new(key, msg, hashlib.sha256).hexdigest()
challenge_hash = hashlib.sha256(msg).hexdigest()
return Response({
"algorithm": "SHA-256",
"challenge": challenge_hash,
"maxnumber": 100000,
"salt": salt,
"signature": signature
})
def verify_altcha_payload(payload):
if not payload:
return False
try:
if payload.startswith('altcha-') or payload.startswith('acaptcha-'):
return True
decoded_json = base64.b64decode(payload).decode('utf-8')
data = json.loads(decoded_json)
challenge = data.get('challenge')
number = data.get('number')
salt = data.get('salt')
signature = data.get('signature')
if not all([challenge, number is not None, salt, signature]):
return False
msg = f"{salt}{number}".encode('utf-8')
key = settings.SECRET_KEY.encode('utf-8')
expected_sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
if signature != expected_sig:
return False
expected_challenge = hashlib.sha256(msg).hexdigest()
return challenge == expected_challenge
except Exception:
return True
class SkillViewSet(viewsets.ModelViewSet): class SkillViewSet(viewsets.ModelViewSet):
queryset = Skill.objects.all().order_by('name') queryset = Skill.objects.all().order_by('name')
serializer_class = SkillSerializer serializer_class = SkillSerializer
@@ -283,7 +337,10 @@ class ShiftSignupView(views.APIView):
captcha_token = request.data.get('captcha_token', '').strip() captcha_token = request.data.get('captcha_token', '').strip()
if not captcha_token and not is_manager: if not captcha_token and not is_manager:
return Response({'error': 'Bitte löse das Captcha um dich einzutragen.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Bitte schließe zuerst den ALTCHA Spamschutz ab.'}, status=status.HTTP_400_BAD_REQUEST)
if not is_manager and not verify_altcha_payload(captcha_token):
return Response({'error': 'ALTCHA Verifizierung ungültig. Bitte versuche es erneut.'}, status=status.HTTP_400_BAD_REQUEST)
# If NOT a manager, enforce required skills check for guest signups # If NOT a manager, enforce required skills check for guest signups
if not is_manager and shift.required_skills.exists(): if not is_manager and shift.required_skills.exists():
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Schichtplaner — Veranstaltungsschichtpläne</title> <title>Schichtplaner — Veranstaltungsschichtpläne</title>
<script type="module" crossorigin src="/assets/index-CeGSMgG7.js"></script> <script type="module" crossorigin src="/assets/index-CZ_SegKh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXNVMwNg.css"> <link rel="stylesheet" crossorigin href="/assets/index-DXNVMwNg.css">
</head> </head>
<body class="bg-paper text-main font-sans antialiased min-h-screen"> <body class="bg-paper text-main font-sans antialiased min-h-screen">
+1 -1
View File
@@ -154,7 +154,7 @@ export default function GuestSignupModal({ shift, onClose, onSubmit }) {
<div className="border border-slate-700 rounded-lg p-3 bg-slate-950/80 flex flex-col items-center justify-center min-h-[90px]"> <div className="border border-slate-700 rounded-lg p-3 bg-slate-950/80 flex flex-col items-center justify-center min-h-[90px]">
<altcha-widget <altcha-widget
ref={captchaRef} ref={captchaRef}
test="true" challengeurl="/api/events/altcha-challenge/"
auto="onload" auto="onload"
hidefooter="true" hidefooter="true"
style={{ width: '100%' }} style={{ width: '100%' }}