diff --git a/.env.example b/.env.example index 6c17125f7..ab89f99fa 100644 --- a/.env.example +++ b/.env.example @@ -103,8 +103,10 @@ LDAP_BASE_DN= USE_LOCAL_AUTH= # ReCaptcha -RECAPTCHA_SITE_KEY_V3= -RECAPTCHA_SECRET_KEY_V3= +RECAPTCHA_ENABLED= +RECAPTCHA_PUBLIC_KEY= +RECAPTCHA_PRIVATE_KEY= +RECAPTCHA_THRESHOLD= # Sentry SENTRY_DSN= diff --git a/app/lib/recaptcha_utils.py b/app/lib/recaptcha_utils.py deleted file mode 100644 index 37786dcff..000000000 --- a/app/lib/recaptcha_utils.py +++ /dev/null @@ -1,152 +0,0 @@ -import logging - -import requests -from flask import Markup, current_app, json, request -from wtforms import ValidationError -from wtforms.fields import HiddenField -from wtforms.widgets import HiddenInput - -logger = logging.getLogger(__name__) - -JSONEncoder = json.JSONEncoder - -RECAPTCHA_TEMPLATE = """ - - - -""" - -RECAPTCHA_TEMPLATE_MANUAL = """ - - - -""" - -RECAPTCHA_VERIFY_SERVER = "https://www.google.com/recaptcha/api/siteverify" -RECAPTCHA_ERROR_CODES = { - "missing-input-secret": "The secret parameter is missing.", - "invalid-input-secret": "The secret parameter is invalid or malformed.", - "missing-input-response": "The response parameter is missing.", - "invalid-input-response": "The response parameter is invalid or malformed.", -} - - -class Recaptcha3Validator(object): - """Validates a ReCaptcha.""" - - def __init__(self, message=None): - if message is None: - message = "Please verify that you are not a robot." - self.message = message - - def __call__(self, form, field): - if current_app.testing: - return True - - token = field.data - if not token: - logger.warning( - "Token is not ready or incorrect configuration (check JavaScript error log)." - ) - raise ValidationError(field.gettext(self.message)) - - remote_ip = request.remote_addr - if not Recaptcha3Validator._validate_recaptcha(field, token, remote_ip): - field.recaptcha_error = "incorrect-captcha-sol" - raise ValidationError(field.gettext(self.message)) - - @staticmethod - def _validate_recaptcha(field, response, remote_addr): - """Performs the actual validation.""" - try: - private_key = current_app.config["RECAPTCHA3_PRIVATE_KEY"] - except KeyError: - raise RuntimeError("RECAPTCHA3_PRIVATE_KEY is not set in app config.") - - data = {"secret": private_key, "remoteip": remote_addr, "response": response} - - http_response = requests.post(RECAPTCHA_VERIFY_SERVER, data) - if http_response.status_code != 200: - return False - - json_resp = http_response.json() - if ( - json_resp["success"] - and json_resp["action"] == field.action - and json_resp["score"] > field.score_threshold - ): - logger.info(json_resp) - return True - else: - logger.warning(json_resp) - - for error in json_resp.get("error-codes", []): - if error in RECAPTCHA_ERROR_CODES: - raise ValidationError(RECAPTCHA_ERROR_CODES[error]) - - return False - - -class Recaptcha3Widget(HiddenInput): - def __call__(self, field, **kwargs): - """Returns the recaptcha input HTML.""" - public_key_name = "RECAPTCHA3_PUBLIC_KEY" - try: - public_key = current_app.config[public_key_name] - except KeyError: - raise RuntimeError("{public_key_name} is not set in app config.") - - return Markup( - ( - RECAPTCHA_TEMPLATE - if field.execute_on_load - else RECAPTCHA_TEMPLATE_MANUAL - ).format(public_key=public_key, action=field.action, field_name=field.name) - ) - - -class Recaptcha3Field(HiddenField): - widget = Recaptcha3Widget() - - # error message if recaptcha validation fails - recaptcha_error = None - - def __init__( - self, - action, - score_threshold=0.5, - execute_on_load=True, - validators=None, - **kwargs - ): - """If execute_on_load is False, recaptcha.execute needs to be manually bound to an event to obtain token, - the JavaScript function to call is executeRecaptcha{action}, e.g. onsubmit="executeRecaptchaSignIn" """ - if not action: - # TODO: more validation on action, see https://developers.google.com/recaptcha/docs/v3#actions - # "actions may only contain alphanumeric characters and slashes, and must not be user-specific" - raise RuntimeError("action must not be none or empty.") - - self.action = action - self.execute_on_load = execute_on_load - self.score_threshold = score_threshold - validators = validators or [Recaptcha3Validator()] - super(Recaptcha3Field, self).__init__(validators=validators, **kwargs) diff --git a/app/main/views.py b/app/main/views.py index 9d2230b7c..58fe5efb9 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -5,7 +5,6 @@ """ from flask import ( current_app, - render_template, flash, render_template, request, @@ -18,8 +17,8 @@ from app.lib.db_utils import create_object, update_object from app.lib.email_utils import send_contact_email from app.models import Emails, Users -from app.request.forms import TechnicalSupportForm from . import main +import requests @main.route('/', methods=['GET', 'POST']) @@ -47,9 +46,24 @@ def status(): @main.route('/contact', methods=['GET', 'POST']) @main.route('/technical-support', methods=['GET', 'POST']) def technical_support(): - form = TechnicalSupportForm() - if request.method == 'POST': + if current_app.config['RECAPTCHA_ENABLED']: + try: + # Verify recaptcha token and return error if failed + recaptcha_response = requests.post( + url='https://www.google.com/recaptcha/api/siteverify?secret={}&response={}' + .format(current_app.config["RECAPTCHA_PRIVATE_KEY"], + request.form["g-recaptcha-response"])).json() + + if recaptcha_response['success'] is False or recaptcha_response['score'] < current_app.config[ + "RECAPTCHA_THRESHOLD"]: + flash('Recaptcha failed, please try again.', category='danger') + render_template('main/contact.html') + except: + current_app.logger.exception("Recaptcha failed to get a response.") + flash('Recaptcha failed, please try again.', category='danger') + render_template('main/contact.html') + name = request.form.get('name') email = request.form.get('email') subject = request.form.get('subject') @@ -77,7 +91,7 @@ def technical_support(): else: flash('Cannot send email.', category='danger') error_id = request.args.get('error_id', '') - return render_template('main/contact.html', error_id=error_id, form=form) + return render_template('main/contact.html', error_id=error_id) @main.route('/faq', methods=['GET']) diff --git a/app/request/forms.py b/app/request/forms.py index 99d7b5097..12e555866 100644 --- a/app/request/forms.py +++ b/app/request/forms.py @@ -29,7 +29,6 @@ ) from app.lib.db_utils import get_agency_choices from app.models import Reasons, LetterTemplates, EnvelopeTemplates, CustomRequestForms -from app.lib.recaptcha_utils import Recaptcha3Field class PublicUserRequestForm(Form): @@ -54,8 +53,6 @@ class PublicUserRequestForm(Form): # File Upload request_file = FileField("Upload File (optional, must be less than 20 Mb)") - recaptcha = Recaptcha3Field(action="TestAction", execute_on_load=True) - # Submit Button submit = SubmitField("Submit Request") @@ -122,8 +119,6 @@ class AgencyUserRequestForm(Form): # File Upload request_file = FileField("Upload File (optional, must be less than 20 Mb)") - recaptcha = Recaptcha3Field(action="TestAction", execute_on_load=True) - # Submit Button submit = SubmitField("Submit Request") @@ -180,7 +175,6 @@ class AnonymousRequestForm(Form): # File Upload request_file = FileField("Upload File (optional, must be less than 20 Mb)") - recaptcha = Recaptcha3Field(action="TestAction", execute_on_load=True) submit = SubmitField("Submit Request") def __init__(self): @@ -529,7 +523,3 @@ def __init__(self, request): request.requester.notification_email or request.requester.email ) self.subject.data = "Inquiry about {}".format(request.id) - - -class TechnicalSupportForm(Form): - recaptcha = Recaptcha3Field(action="TestAction", execute_on_load=True) \ No newline at end of file diff --git a/app/request/views.py b/app/request/views.py index 198948734..b0e224d48 100644 --- a/app/request/views.py +++ b/app/request/views.py @@ -61,6 +61,7 @@ from app.user_request.utils import get_current_point_of_contact from app import sentry import json +import requests @request.route("/new", methods=["GET", "POST"]) @@ -97,6 +98,37 @@ def new(): new_request_template = "request/new_request_" + template_suffix if flask_request.method == "POST": + if current_app.config['RECAPTCHA_ENABLED']: + try: + # Verify recaptcha token and return error if failed + recaptcha_response = requests.post( + url='https://www.google.com/recaptcha/api/siteverify?secret={}&response={}' + .format(current_app.config["RECAPTCHA_PRIVATE_KEY"], + flask_request.form["g-recaptcha-response"])).json() + + if recaptcha_response['success'] is False or recaptcha_response['score'] < current_app.config[ + "RECAPTCHA_THRESHOLD"]: + flash('Recaptcha failed, please try again.', category='danger') + return render_template( + new_request_template, + form=form, + kiosk_mode=kiosk_mode, + category=category, + agency=agency, + title=title, + ) + except: + current_app.logger.exception("Recaptcha failed to get a response.") + flash('Recaptcha failed, please try again.', category='danger') + return render_template( + new_request_template, + form=form, + kiosk_mode=kiosk_mode, + category=category, + agency=agency, + title=title, + ) + # validate upload with no request id available upload_path = None if form.request_file.data: diff --git a/app/static/js/request/main.js b/app/static/js/request/main.js index c683da53f..b8f32601b 100644 --- a/app/static/js/request/main.js +++ b/app/static/js/request/main.js @@ -842,4 +842,18 @@ function handlePIIModalReview(){ $(window).scrollTop($('#request-title').offset().top - 50); showPIIWarning = true; return; +} + +/** + * Handles data-callback for submitting new requests when using recaptcha. + */ +function onSubmitRequest(token) { + $("#request-form").submit(); +} + +/** + * Handles data-callback for submitting technical support emails when using recaptcha. + */ +function onSubmitTechnicalSupport(token) { + $("#contact-info").submit(); } \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 04c8a078f..d7158d453 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -76,6 +76,7 @@ + {{ moment.include_moment(local_js=url_for('static', filename='js/plugins/moment.min.js')) }} diff --git a/app/templates/main/contact.html b/app/templates/main/contact.html index a06032265..17b3e79d3 100644 --- a/app/templates/main/contact.html +++ b/app/templates/main/contact.html @@ -33,8 +33,19 @@

Technical Support

5000 characters remaining


- {{ form.recaptcha }} - + {% if config['RECAPTCHA_ENABLED'] %} + + {% else %} + + {% endif %} diff --git a/app/templates/request/new_request_agency.html b/app/templates/request/new_request_agency.html index 82a6c158f..270adf90c 100644 --- a/app/templates/request/new_request_agency.html +++ b/app/templates/request/new_request_agency.html @@ -245,8 +245,18 @@

Address

placeholder="12345") }}
- {{ form.recaptcha }} - {{ form.submit(id="submit", class="btn-primary") }} + {% if config['RECAPTCHA_ENABLED'] %} + + {% else %} + {{ form.submit(id="submit", class="btn-primary") }} + {% endif %} {% include "request/_pii_warning_modal.html" %}