Source code for ferrosoft.apps.ferrobase.services.captcha
# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""Lightweight image-based CAPTCHA used on public forms.
Generates a small arithmetic puzzle, renders it as a PNG, and stores the
expected answer in the session for later verification.
"""
import base64
import io
import random
from PIL import ImageFont, Image, ImageDraw
from django.http import HttpRequest
from ferrosoft.strparse import parse_int_noexcept
[docs]
class Captcha:
"""Arithmetic-puzzle CAPTCHA bound to a Django session.
Each instance picks two random addends (or the fixed pair ``[1, 1]`` when
the ``FERROBASE_INSECURE_CAPTCHA`` setting is enabled — an escape hatch
for end-to-end tests). :meth:`set_challenge` stores the expected answer
in the session and :meth:`verify` checks the submitted POST value.
"""
FONT_SIZE = 16
SESSION_KEY = "captcha_challenge"
POST_KEY = "probe"
INSECURE_OPTION = "FERROBASE_INSECURE_CAPTCHA"
def __init__(self):
"""Choose the puzzle addends, honouring the insecure-test override."""
from django.conf import settings
if getattr(settings, self.INSECURE_OPTION, False):
# This is an escape hatch for E2E tests.
self._addends = [1, 1]
else:
self._addends = [
random.randint(1, 10),
random.randint(1, 10),
]
[docs]
def set_challenge(self, request: HttpRequest):
"""Store the expected answer (sum of the addends) in the session."""
result = sum(self._addends)
request.session[self.SESSION_KEY] = result
[docs]
def verify(self, request: HttpRequest) -> bool:
"""Return ``True`` when the POST submission matches the stored challenge."""
challenge = request.session.get(self.SESSION_KEY, None)
if not challenge:
return False
user_value = parse_int_noexcept(request.POST.get(self.POST_KEY, None))
return challenge == user_value
[docs]
def make_image(self) -> str:
"""Create base64 encoded PNG image from text."""
text = " + ".join([str(it) for it in self._addends]) + " ="
try:
font = ImageFont.truetype("DejaVuSans.ttf", self.FONT_SIZE)
except:
font = ImageFont.load_default(self.FONT_SIZE)
dummy_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(dummy_img)
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
img = Image.new("RGB", (text_width + 20, text_height + 20), color="white")
draw = ImageDraw.Draw(img)
draw.text((10, 10), text, font=font, fill="black")
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
return img_base64