Feat: Update CAPTCHA service to ALTCHA self-hosted.

This commit is contained in:
2025-03-15 17:47:36 +00:00
parent b843849af9
commit bade1f11dd
10 changed files with 3823 additions and 153 deletions

View File

@@ -42,7 +42,6 @@ class Config:
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Strict' SESSION_COOKIE_SAMESITE = 'Strict'
REMEMBER_COOKIE_SECURE = True REMEMBER_COOKIE_SECURE = True
# PERMANENT_SESSION_LIFETIME = 3600
WTF_CSRF_ENABLED = True WTF_CSRF_ENABLED = True
# WTF_CSRF_CHECK_DEFAULT = False # We'll check it manually for API routes # WTF_CSRF_CHECK_DEFAULT = False # We'll check it manually for API routes
# WTF_CSRF_HEADERS = ['X-CSRFToken'] # Accept CSRF token from this header # WTF_CSRF_HEADERS = ['X-CSRFToken'] # Accept CSRF token from this header

View File

@@ -14,6 +14,7 @@ Initializes the Flask application, sets the configuration based on the environme
# internal # internal
from datastores.datastore_base import DataStore_Base from datastores.datastore_base import DataStore_Base
from forms.contact import Form_Contact from forms.contact import Form_Contact
from helpers.helper_app import Helper_App
from models.model_view_contact import Model_View_Contact from models.model_view_contact import Model_View_Contact
from models.model_view_home import Model_View_Home from models.model_view_home import Model_View_Home
import lib.argument_validation as av import lib.argument_validation as av
@@ -29,6 +30,8 @@ import json
import base64 import base64
import hmac import hmac
import hashlib import hashlib
import datetime
from altcha import ChallengeOptions, create_challenge, verify_solution
routes_core = Blueprint('routes_core', __name__) routes_core = Blueprint('routes_core', __name__)
@@ -56,35 +59,12 @@ def contact():
def contact_post(): def contact_post():
try: try:
form = Form_Contact() form = Form_Contact()
Helper_App.console_log(f"Form submitted: {request.form}")
Helper_App.console_log(f"ALTCHA data in request: {request.form.get('altcha')}")
if form.validate_on_submit(): if form.validate_on_submit():
altcha_payload = form.altcha.data
if not altcha_payload:
flash('Please complete the ALTCHA challenge', 'danger')
return "Invalid. ALTCHA challenge failed."
# Decode and verify the ALTCHA payload
try: try:
decoded_payload = json.loads(base64.b64decode(altcha_payload))
# Verify the signature
if verify_altcha_signature(decoded_payload):
# Parse the verification data
verification_data = urllib.parse.parse_qs(decoded_payload['verificationData'])
# Check if the verification was successful
if verification_data.get('verified', ['false'])[0] == 'true':
# If spam filter is enabled, check the classification
if 'classification' in verification_data:
classification = verification_data.get('classification', [''])[0]
score = float(verification_data.get('score', ['0'])[0])
# If the classification is BAD and score is high, reject the submission
if classification == 'BAD' and score > 5:
flash('Your submission was flagged as potential spam', 'error')
return render_template('contact.html', form=form)
# Process the form submission
email = form.email.data email = form.email.data
CC = form.CC.data # not in use # CC = form.CC.data # not in use
contact_name = form.contact_name.data contact_name = form.contact_name.data
company_name = form.company_name.data company_name = form.company_name.data
message = form.message.data message = form.message.data
@@ -96,18 +76,32 @@ def contact_post():
mail.send(mailItem) mail.send(mailItem)
flash('Thank you for your message. We will get back to you soon!', 'success') flash('Thank you for your message. We will get back to you soon!', 'success')
return "Submitted." return "Submitted."
else:
flash('CAPTCHA verification failed', 'error')
else:
flash('Invalid verification signature', 'error')
except Exception as e: except Exception as e:
flash(f'Error verifying CAPTCHA: {str(e)}', 'error') return f"Error: {e}"
print(f"Form validation errors: {form.errors}")
return "Invalid. Failed to submit." return "Invalid. Failed to submit."
# html_body = render_template('pages/core/_contact.html', model = model) # html_body = render_template('pages/core/_contact.html', model = model)
except Exception as e: except Exception as e:
return jsonify(error=str(e)), 403 return jsonify(error=str(e)), 403
@routes_core.route(Model_View_Contact.HASH_ALTCHA_CREATE_CHALLENGE, methods=['GET'])
def create_altcha_challenge():
options = ChallengeOptions(
expires = datetime.datetime.now() + datetime.timedelta(hours=1),
max_number = 100000, # The maximum random number
hmac_key = current_app.config["ALTCHA_SECRET_KEY"],
)
challenge = create_challenge(options)
print("Challenge created:", challenge)
# return jsonify({"challenge": challenge})
return jsonify({
"algorithm": challenge.algorithm,
"challenge": challenge.challenge,
"salt": challenge.salt,
"signature": challenge.signature,
})
"""
def verify_altcha_signature(payload): def verify_altcha_signature(payload):
"" "Verify the ALTCHA signature"" " "" "Verify the ALTCHA signature"" "
if 'algorithm' not in payload or 'signature' not in payload or 'verificationData' not in payload: if 'algorithm' not in payload or 'signature' not in payload or 'verificationData' not in payload:
@@ -136,3 +130,29 @@ def verify_altcha_signature(payload):
# Compare the calculated signature with the provided signature # Compare the calculated signature with the provided signature
return hmac.compare_digest(calculated_signature, signature) return hmac.compare_digest(calculated_signature, signature)
def create_altcha_dummy_signature(challenge):
# Example payload to verify
payload = {
"algorithm": challenge.algorithm,
"challenge": challenge.challenge,
"number": 12345, # Example number
"salt": challenge.salt,
"signature": challenge.signature,
}
return payload
@routes_core.route(Model_View_Contact.HASH_ALTCHA_VERIFY_SOLUTION, methods=['POST'])
def verify_altcha_challenge():
payload = request.json
ok, err = verify_solution(payload, current_app.config["ALTCHA_SECRET_KEY"], check_expires=True)
if err:
return jsonify({"error": err}), 400
elif ok:
return jsonify({"verified": True})
else:
return jsonify({"verified": False}), 403
"""

View File

@@ -14,65 +14,66 @@ Defines Flask-WTF form for handling user input on Contact Us page.
# internal # internal
# from business_objects.store.product_category import Filters_Product_Category # circular # from business_objects.store.product_category import Filters_Product_Category # circular
# from models.model_view_store import Model_View_Store # circular # from models.model_view_store import Model_View_Store # circular
from models.model_view_base import Model_View_Base
from forms.base import Form_Base from forms.base import Form_Base
# external # external
from flask import Flask, render_template, request, flash, redirect, url_for, current_app from flask import Flask, render_template, request, flash, redirect, url_for, current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, HiddenField, BooleanField from wtforms import StringField, TextAreaField, SubmitField, HiddenField, BooleanField, Field
from wtforms.validators import DataRequired, Email, ValidationError from wtforms.validators import DataRequired, Email, ValidationError
import markupsafe
from flask_wtf.recaptcha import RecaptchaField from flask_wtf.recaptcha import RecaptchaField
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
import requests
import json import json
import hmac from altcha import verify_solution
import hashlib
import base64 import base64
import urllib.parse
""" class ALTCHAValidator:
def validate_altcha(form, field): def __init__(self, message=None):
if not field.data: self.message = message or 'ALTCHA verification failed'
raise ValidationError('Please complete the ALTCHA challenge')
def __call__(self, form, field):
altcha_data = field.data
if not altcha_data:
raise ValidationError(self.message)
try: try:
# Decode the base64-encoded payload # The data is base64 encoded JSON
payload_json = base64.b64decode(field.data).decode('utf-8') try:
payload = json.loads(payload_json) # First try to decode it as JSON directly (if it's not base64 encoded)
altcha_payload = json.loads(altcha_data)
except json.JSONDecodeError:
# If direct JSON decoding fails, try base64 decoding first
decoded_data = base64.b64decode(altcha_data).decode('utf-8')
altcha_payload = json.loads(decoded_data)
# Verify ALTCHA response ok, err = verify_solution(altcha_payload, current_app.config["ALTCHA_SECRET_KEY"], check_expires=True)
if not payload.get('verified', False):
raise ValidationError('ALTCHA verification failed')
# Verify signature if err or not ok:
verification_data = payload.get('verificationData', '') raise ValidationError(self.message + ': ' + (err or 'Invalid solution'))
received_signature = payload.get('signature', '')
algorithm = payload.get('algorithm', 'SHA-256')
# Calculate the hash of verification data
verification_hash = hashlib.sha256(verification_data.encode()).digest()
# Calculate HMAC signature
hmac_key = current_app.config['ALTCHA_SECRET_KEY'].encode()
calculated_signature = hmac.new(
hmac_key,
verification_hash,
getattr(hashlib, algorithm.lower().replace('-', ''))
).hexdigest()
if calculated_signature != received_signature:
raise ValidationError('Invalid ALTCHA signature')
# Optional: If using the spam filter, you could parse verification_data
# and reject submissions classified as spam
# Example:
parsed_data = dict(urllib.parse.parse_qsl(verification_data))
if parsed_data.get('classification') == 'BAD':
raise ValidationError('This submission was classified as spam')
except Exception as e: except Exception as e:
current_app.logger.error(f"ALTCHA validation error: {str(e)}") raise ValidationError(f'Invalid ALTCHA data: {str(e)}')
raise ValidationError('ALTCHA validation failed')
class ALTCHAField(Field):
def __init__(self, label='', validators=None, **kwargs):
validators = validators or []
validators.append(ALTCHAValidator())
super(ALTCHAField, self).__init__(label, validators, **kwargs)
def __call__(self, **kwargs):
html = f"""
<altcha-widget
challengeurl="/get-challenge"
auto="onload"
id="{self.id}"
name="{self.name}">
</altcha-widget>
""" """
return markupsafe.Markup(html)
class Form_Contact(FlaskForm): class Form_Contact(FlaskForm):
email = StringField('Email') email = StringField('Email')
@@ -81,5 +82,6 @@ class Form_Contact(FlaskForm):
message = TextAreaField('Message') message = TextAreaField('Message')
receive_marketing = BooleanField('I would like to receive marketing emails.') receive_marketing = BooleanField('I would like to receive marketing emails.')
# recaptcha = RecaptchaField() # recaptcha = RecaptchaField()
altcha = HiddenField('ALTCHA') # , validators=[validate_altcha] # altcha = HiddenField('ALTCHA') # , validators=[validate_altcha]
altcha = ALTCHAField('Verification')
submit = SubmitField('Send Message') submit = SubmitField('Send Message')

View File

@@ -149,6 +149,8 @@ class Model_View_Base(BaseModel, ABC):
FLAG_USER: ClassVar[str] = User.FLAG_USER FLAG_USER: ClassVar[str] = User.FLAG_USER
FLAG_WEBSITE: ClassVar[str] = Base.FLAG_WEBSITE FLAG_WEBSITE: ClassVar[str] = Base.FLAG_WEBSITE
# flagIsDatePicker: ClassVar[str] = 'is-date-picker' # flagIsDatePicker: ClassVar[str] = 'is-date-picker'
HASH_ALTCHA_CREATE_CHALLENGE: ClassVar[str] = '/altcha/create-challenge'
# HASH_ALTCHA_VERIFY_SOLUTION: ClassVar[str] = '/altcha/verify-solution'
HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION: ClassVar[str] = '/store/permutation_filter' HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION: ClassVar[str] = '/store/permutation_filter'
HASH_CALLBACK_LOGIN: ClassVar[str] = '/callback-login' HASH_CALLBACK_LOGIN: ClassVar[str] = '/callback-login'
HASH_PAGE_ACCESSIBILITY_REPORT: ClassVar[str] = '/accessibility-report' HASH_PAGE_ACCESSIBILITY_REPORT: ClassVar[str] = '/accessibility-report'

View File

@@ -22,19 +22,11 @@ from pydantic import BaseModel
from typing import ClassVar from typing import ClassVar
class Model_View_Contact(Model_View_Base): class Model_View_Contact(Model_View_Base):
# Attributes FLAG_ALTCHA_WIDGET: ClassVar[str] = 'altcha-widget'
FLAG_COMPANY_NAME: ClassVar[str] = 'company_name' FLAG_COMPANY_NAME: ClassVar[str] = 'company_name'
FLAG_CONTACT_NAME: ClassVar[str] = 'contact_name' FLAG_CONTACT_NAME: ClassVar[str] = 'contact_name'
FLAG_RECEIVE_MARKETING: ClassVar[str] = 'receive_marketing' FLAG_RECEIVE_MARKETING: ClassVar[str] = 'receive_marketing'
ID_CONTACT_FORM: ClassVar[str] = 'contact-form' ID_CONTACT_FORM: ClassVar[str] = 'contact-form'
"""
ID_EMAIL: ClassVar[str] = 'email'
ID_COMPANY_NAME: ClassVar[str] = 'company_name'
ID_CONTACT_NAME: ClassVar[str] = 'contact_name'
ID_MESSAGE: ClassVar[str] = 'msg'
ID_RECEIVE_MARKETING: ClassVar[str] = 'receive_marketing'
ID_NAME: ClassVar[str] = 'name'
"""
form_contact: Form_Contact form_contact: Form_Contact

View File

@@ -15,3 +15,4 @@ pydantic
# psycopg2 # psycopg2
requests requests
cryptography cryptography
altcha

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,7 @@
// internal
import BasePage from "../base.js"; import BasePage from "../base.js";
// vendor
import { Altcha } from "../../vendor/altcha.js";
export default class PageContact extends BasePage { export default class PageContact extends BasePage {
static hash = hashPageContact; static hash = hashPageContact;
@@ -10,11 +12,12 @@ export default class PageContact extends BasePage {
initialize() { initialize() {
this.sharedInitialize(); this.sharedInitialize();
this.hookupCaptcha(); // this.hookupALTCHAByLocalServer();
this.hookupButtonSubmitFormContactUs(); this.hookupButtonSubmitFormContactUs();
} }
hookupCaptcha() { /*
hookupALTCHAByAPI() {
const form = document.querySelector(idContactForm); const form = document.querySelector(idContactForm);
const altchaWidget = form.querySelector('altcha-widget'); const altchaWidget = form.querySelector('altcha-widget');
@@ -35,6 +38,22 @@ export default class PageContact extends BasePage {
}); });
} }
} }
*/
hookupALTCHAByLocalServer() {
window.ALTCHA = { init: (config) => {
document.querySelectorAll(config.selector).forEach(el => {
new Altcha({
target: el,
props: {
challengeurl: config.challenge.url,
auto: 'onload'
}
}).$on('verified', (e) => {
config.challenge.onSuccess(e.detail.payload, el);
});
});
}};
}
hookupButtonSubmitFormContactUs() { hookupButtonSubmitFormContactUs() {
const button = document.querySelector('form input[type="submit"]'); const button = document.querySelector('form input[type="submit"]');

View File

@@ -132,6 +132,7 @@
var flagTemporaryElement = "{{ model.FLAG_TEMPORARY_ELEMENT }}"; var flagTemporaryElement = "{{ model.FLAG_TEMPORARY_ELEMENT }}";
var flagUser = "{{ model.FLAG_USER }}"; var flagUser = "{{ model.FLAG_USER }}";
var flagWebsite = "{{ model.FLAG_WEBSITE }}"; var flagWebsite = "{{ model.FLAG_WEBSITE }}";
var hashALTCHACreateChallenge = "{{ model.HASH_ALTCHA_CREATE_CHALLENGE }}";
var hashApplyFiltersStoreProductPermutation = "{{ model.HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION }}"; var hashApplyFiltersStoreProductPermutation = "{{ model.HASH_APPLY_FILTERS_STORE_PRODUCT_PERMUTATION }}";
var hashPageAccessibilityReport = "{{ model.HASH_PAGE_ACCESSIBILITY_REPORT }}"; var hashPageAccessibilityReport = "{{ model.HASH_PAGE_ACCESSIBILITY_REPORT }}";
var hashPageAccessibilityStatement = "{{ model.HASH_PAGE_ACCESSIBILITY_STATEMENT }}"; var hashPageAccessibilityStatement = "{{ model.HASH_PAGE_ACCESSIBILITY_STATEMENT }}";

View File

@@ -5,7 +5,22 @@
{# {#
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/@altcha/browser@latest/dist/index.js" defer></script> <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/@altcha/browser@latest/dist/index.js" defer></script>
#} #}
{# with CDN
<script src="https://cdn.jsdelivr.net/npm/@altcha/browser@1.1.0/dist/altcha.min.js"></script>
<style>
.altcha-widget {
margin: 15px 0;
}
</style>
#}
{# with locally stored vendor project - this is imported into contact.js
<script type="module" src="{{ url_for('static', filename='js/vendor/altcha.js')}}"></script> <script type="module" src="{{ url_for('static', filename='js/vendor/altcha.js')}}"></script>
#}
<style>
.altcha-widget {
margin: 15px 0;
}
</style>
{% endblock %} {% endblock %}
{% block page_nav_links %} {% block page_nav_links %}
@@ -35,6 +50,16 @@
<h1>Contact Us</h1> <h1>Contact Us</h1>
<p>Please fill in the form below and we'll get back to you as soon as possible.</p> <p>Please fill in the form below and we'll get back to you as soon as possible.</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class="flashes">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<form id="{{ model.ID_CONTACT_FORM }}" method="POST" action="{{ url_for('routes_core.contact') }}"> <form id="{{ model.ID_CONTACT_FORM }}" method="POST" action="{{ url_for('routes_core.contact') }}">
{{ form.csrf_token }} {{ form.csrf_token }}
@@ -66,10 +91,26 @@
</div> </div>
<div class="{{ model.FLAG_CONTAINER }} {{ model.FLAG_CAPTCHA }}"> <div class="{{ model.FLAG_CONTAINER }} {{ model.FLAG_CAPTCHA }}">
{# {{ model.form_contact.recaptcha() }} #} {# {{ model.form_contact.recaptcha() }} #}
{#
<altcha-widget <altcha-widget
challengeurl="https://eu.altcha.org/api/v1/challenge?apiKey={{ model.app.app_config.ALTCHA_API_KEY }}" challengeurl="https://eu.altcha.org/api/v1/challenge?apiKey={{ model.app.app_config.ALTCHA_API_KEY }}"
spamfilter spamfilter
></altcha-widget> ></altcha-widget>
#}
<div>
{{ form.altcha.label }}
{#
{{ form.altcha }}
{{ form.altcha.hidden() }}
#}
<altcha-widget
class="altcha-widget"
challengeurl="{{ url_for('routes_core.create_altcha_challenge') }}"
auto="onload"
id="{{ form.altcha.id }}"
name="{{ form.altcha.name }}"
></altcha-widget>
</div>
</div> </div>
<div class="{{ model.FLAG_CONTAINER_INPUT }}"> <div class="{{ model.FLAG_CONTAINER_INPUT }}">
{{ model.form_contact.submit() }} {{ model.form_contact.submit() }}
@@ -109,7 +150,45 @@
</section> </section>
#} #}
{# with CDN
<script> <script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize ALTCHA widget
ALTCHA.init({
selector: '.altcha-widget',
challenge: {
url: '/get-challenge',
onSuccess: function(result, element) {
// Store the result in the hidden input field
const hiddenInput = element.parentNode.querySelector('input[type="hidden"]');
hiddenInput.value = JSON.stringify(result);
}
}
});
});
</script>
#}
{# with locally stored vendor project - this is now in contact.js
<script type="module">
import { Altcha } from "{{ url_for('static', filename='js/vendor/altcha.js') }}";
window.ALTCHA = { init: (config) => {
document.querySelectorAll(config.selector).forEach(el => {
new Altcha({
target: el,
props: {
challengeurl: config.challenge.url,
auto: 'onload'
}
}).$on('verified', (e) => {
config.challenge.onSuccess(e.detail.payload, el);
});
});
}};
</script>
#}
<script>
var flagALTCHAWidget = "{{ model.FLAG_ALTCHA_WIDGET }}";
var idContactForm = "#{{ model.ID_CONTACT_FORM }}"; var idContactForm = "#{{ model.ID_CONTACT_FORM }}";
var idEmail = "#{{ model.ID_EMAIL }}"; var idEmail = "#{{ model.ID_EMAIL }}";
var idMessage = "#{{ model.ID_MESSAGE }}"; var idMessage = "#{{ model.ID_MESSAGE }}";