feat: Add native Django ALTCHA challenge generation and server-side HMAC validation endpoint
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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)),
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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">
|
||||||
|
|||||||
@@ -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%' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user