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,60 +59,51 @@ 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)) email = form.email.data
# CC = form.CC.data # not in use
# Verify the signature contact_name = form.contact_name.data
if verify_altcha_signature(decoded_payload): company_name = form.company_name.data
# Parse the verification data message = form.message.data
verification_data = urllib.parse.parse_qs(decoded_payload['verificationData']) receive_marketing = form.receive_marketing.data
receive_marketing_text = "I would like to receive marketing emails." if receive_marketing else ""
# Check if the verification was successful # send email
if verification_data.get('verified', ['false'])[0] == 'true': mailItem = Message("PARTS Website Contact Us Message", recipients=[current_app.config['MAIL_CONTACT_PUBLIC']])
# If spam filter is enabled, check the classification mailItem.body = f"Dear Lord Edward Middleton-Smith,\n\n{message}\n{receive_marketing_text}\nKind regards,\n{contact_name}\n{company_name}\n{email}"
if 'classification' in verification_data: mail.send(mailItem)
classification = verification_data.get('classification', [''])[0] flash('Thank you for your message. We will get back to you soon!', 'success')
score = float(verification_data.get('score', ['0'])[0]) return "Submitted."
# 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
CC = form.CC.data # not in use
contact_name = form.contact_name.data
company_name = form.company_name.data
message = form.message.data
receive_marketing = form.receive_marketing.data
receive_marketing_text = "I would like to receive marketing emails." if receive_marketing else ""
# send email
mailItem = Message("PARTS Website Contact Us Message", recipients=[current_app.config['MAIL_CONTACT_PUBLIC']])
mailItem.body = f"Dear Lord Edward Middleton-Smith,\n\n{message}\n{receive_marketing_text}\nKind regards,\n{contact_name}\n{company_name}\n{email}"
mail.send(mailItem)
flash('Thank you for your message. We will get back to you soon!', 'success')
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:
return False return False
@@ -135,4 +129,30 @@ def verify_altcha_signature(payload):
).hexdigest() ).hexdigest()
# 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')
try:
# Decode the base64-encoded payload
payload_json = base64.b64decode(field.data).decode('utf-8')
payload = json.loads(payload_json)
# Verify ALTCHA response def __call__(self, form, field):
if not payload.get('verified', False): altcha_data = field.data
raise ValidationError('ALTCHA verification failed')
# Verify signature if not altcha_data:
verification_data = payload.get('verificationData', '') raise ValidationError(self.message)
received_signature = payload.get('signature', '')
algorithm = payload.get('algorithm', 'SHA-256')
# Calculate the hash of verification data try:
verification_hash = hashlib.sha256(verification_data.encode()).digest() # The data is base64 encoded JSON
try:
# Calculate HMAC signature # First try to decode it as JSON directly (if it's not base64 encoded)
hmac_key = current_app.config['ALTCHA_SECRET_KEY'].encode() altcha_payload = json.loads(altcha_data)
calculated_signature = hmac.new( except json.JSONDecodeError:
hmac_key, # If direct JSON decoding fails, try base64 decoding first
verification_hash, decoded_data = base64.b64decode(altcha_data).decode('utf-8')
getattr(hashlib, algorithm.lower().replace('-', '')) altcha_payload = json.loads(decoded_data)
).hexdigest()
ok, err = verify_solution(altcha_payload, current_app.config["ALTCHA_SECRET_KEY"], check_expires=True)
if calculated_signature != received_signature:
raise ValidationError('Invalid ALTCHA signature')
# Optional: If using the spam filter, you could parse verification_data if err or not ok:
# and reject submissions classified as spam raise ValidationError(self.message + ': ' + (err or 'Invalid solution'))
# Example:
parsed_data = dict(urllib.parse.parse_qsl(verification_data)) except Exception as e:
if parsed_data.get('classification') == 'BAD': raise ValidationError(f'Invalid ALTCHA data: {str(e)}')
raise ValidationError('This submission was classified as spam')
class ALTCHAField(Field):
except Exception as e: def __init__(self, label='', validators=None, **kwargs):
current_app.logger.error(f"ALTCHA validation error: {str(e)}") validators = validators or []
raise ValidationError('ALTCHA validation failed') 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

@@ -14,4 +14,5 @@ authlib
pydantic 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>
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> <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 }}";