Skip to content

Commit 63047cc

Browse files
authored
feat: add logging for validation failures and misconfiguration (#46)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 0eb77f0 commit 63047cc

3 files changed

Lines changed: 127 additions & 0 deletions

File tree

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,48 @@ Only loaded when `ALTCHA_INCLUDE_TRANSLATIONS` is `True`.
217217
Set to `False` to skip Altcha validation altogether.
218218
Defaults to `True`.
219219

220+
## Logging
221+
222+
Django Altcha uses the standard Python `logging` module under the logger name
223+
`django_altcha`. No logs are emitted under normal operation; logging fires only
224+
on validation failures and misconfiguration.
225+
226+
### What gets logged
227+
228+
- **WARNING** on invalid or missing CAPTCHA tokens submitted to a form.
229+
- **WARNING** on replay attempts (a challenge reused after it has already been validated).
230+
- **ERROR** when `ALTCHA_HMAC_KEY` is not configured.
231+
- **Exception with traceback** when verification or payload decoding raises
232+
unexpectedly.
233+
234+
Payloads, challenge values, and the HMAC key are never included in log
235+
messages.
236+
237+
### Enabling logs
238+
239+
Add the `django_altcha` logger to your project's `LOGGING` setting:
240+
241+
```python
242+
LOGGING = {
243+
"version": 1,
244+
"disable_existing_loggers": False,
245+
"handlers": {
246+
"console": {
247+
"class": "logging.StreamHandler",
248+
},
249+
},
250+
"loggers": {
251+
"django_altcha": {
252+
"handlers": ["console"],
253+
"level": "WARNING",
254+
},
255+
},
256+
}
257+
```
258+
259+
Set `"level": "ERROR"` to see only misconfiguration and unexpected failures,
260+
or `"level": "DEBUG"` to see additional diagnostic messages during development.
261+
220262
## Contributing
221263

222264
We welcome contributions to improve this library.

django_altcha/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import base64
99
import datetime
1010
import json
11+
import logging
1112

1213
from django import forms
1314
from django.core.cache import caches
@@ -26,11 +27,14 @@
2627
__version__ = "0.10.0"
2728
VERSION = __version__
2829

30+
logger = logging.getLogger(__name__)
31+
2932

3033
def get_hmac_key():
3134
"""Return the HMAC key, raising if not configured."""
3235
hmac_key = get_setting("ALTCHA_HMAC_KEY")
3336
if not hmac_key:
37+
logger.error("ALTCHA_HMAC_KEY setting is not configured")
3438
raise ImproperlyConfigured("The ALTCHA_HMAC_KEY setting must be provided.")
3539
return hmac_key
3640

@@ -228,11 +232,15 @@ def __init__(self, *args, **kwargs):
228232
def validate(self, value):
229233
"""Validate the CAPTCHA token and verify its authenticity."""
230234
if not get_setting("ALTCHA_VERIFICATION_ENABLED"):
235+
logger.debug(
236+
"ALTCHA validation skipped: ALTCHA_VERIFICATION_ENABLED is False"
237+
)
231238
return
232239

233240
super().validate(value)
234241

235242
if not value:
243+
logger.warning("ALTCHA validation failed: missing token")
236244
raise forms.ValidationError(
237245
self.error_messages["required"], code="required"
238246
)
@@ -244,9 +252,11 @@ def validate(self, value):
244252
check_expires=True,
245253
)
246254
except Exception:
255+
logger.exception("ALTCHA validation raised an unexpected exception")
247256
raise forms.ValidationError(self.error_messages["error"], code="error")
248257

249258
if not verified:
259+
logger.warning("ALTCHA validation failed: %s", error)
250260
raise forms.ValidationError(self.error_messages["invalid"], code="invalid")
251261

252262
self.replay_attack_protection(payload=value)
@@ -258,9 +268,13 @@ def replay_attack_protection(self, payload):
258268
payload_data = json.loads(base64.b64decode(payload).decode())
259269
challenge = payload_data["challenge"]
260270
except Exception:
271+
logger.exception(
272+
"ALTCHA payload could not be decoded for replay protection"
273+
)
261274
raise forms.ValidationError(self.error_messages["error"], code="error")
262275

263276
if is_challenge_used(challenge):
277+
logger.warning("ALTCHA replay attack detected: challenge already used")
264278
raise forms.ValidationError(self.error_messages["replay"], code="invalid")
265279

266280
# Mark as used for the same duration as challenge expiration
@@ -281,4 +295,5 @@ def get(self, request, *args, **kwargs):
281295
expires = kwargs.get("expires", self.expires)
282296

283297
challenge = get_altcha_challenge(max_number=max_number, expires=expires)
298+
logger.debug("ALTCHA challenge issued")
284299
return JsonResponse(challenge.__dict__)

tests/test_logging.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# SPDX-License-Identifier: MIT
4+
# See https://github.com/aboutcode-org/django-altcha for support or download.
5+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
6+
#
7+
8+
from unittest import mock
9+
10+
from django import forms
11+
from django.core.exceptions import ImproperlyConfigured
12+
from django.test import TestCase
13+
from django.test import override_settings
14+
15+
from django_altcha import AltchaField
16+
17+
from .test_field import make_valid_payload
18+
19+
20+
class AltchaFieldLoggingTest(TestCase):
21+
def setUp(self):
22+
class TestForm(forms.Form):
23+
altcha_field = AltchaField()
24+
25+
self.form_class = TestForm
26+
27+
@mock.patch("altcha.verify_solution")
28+
def test_invalid_token_logs_warning(self, mock_verify_solution):
29+
mock_verify_solution.return_value = (False, "bad signature")
30+
form = self.form_class(data={"altcha_field": "anything"})
31+
with self.assertLogs("django_altcha", level="WARNING") as captured:
32+
form.is_valid()
33+
self.assertEqual(len(captured.records), 1)
34+
self.assertEqual(captured.records[0].levelname, "WARNING")
35+
self.assertIn("bad signature", captured.output[0])
36+
37+
@mock.patch("altcha.verify_solution")
38+
def test_verification_exception_is_logged_with_traceback(
39+
self, mock_verify_solution
40+
):
41+
mock_verify_solution.side_effect = RuntimeError("boom")
42+
form = self.form_class(data={"altcha_field": "anything"})
43+
with self.assertLogs("django_altcha", level="ERROR") as captured:
44+
form.is_valid()
45+
self.assertEqual(captured.records[0].levelname, "ERROR")
46+
# Confirms traceback is captured
47+
self.assertIsNotNone(captured.records[0].exc_info)
48+
49+
@mock.patch("altcha.verify_solution")
50+
def test_replay_attempt_logs_warning(self, mock_verify_solution):
51+
mock_verify_solution.return_value = (True, None)
52+
valid_payload = make_valid_payload(challenge="replay-test-1")
53+
# First submission succeeds and marks the challenge as used.
54+
self.form_class(data={"altcha_field": valid_payload}).is_valid()
55+
# Second submission should log a replay warning.
56+
form = self.form_class(data={"altcha_field": valid_payload})
57+
with self.assertLogs("django_altcha", level="WARNING") as captured:
58+
form.is_valid()
59+
self.assertTrue(
60+
any("replay" in r.getMessage().lower() for r in captured.records)
61+
)
62+
63+
@override_settings(ALTCHA_HMAC_KEY=None)
64+
def test_missing_hmac_key_logs_error(self):
65+
from django_altcha import get_hmac_key
66+
67+
with self.assertLogs("django_altcha", level="ERROR") as captured:
68+
with self.assertRaises(ImproperlyConfigured):
69+
get_hmac_key()
70+
self.assertEqual(captured.records[0].levelname, "ERROR")

0 commit comments

Comments
 (0)