The purpose of this is to show how to incorporate Google’s reCAPTCHA Enterprise v3 (invisible) assessment score into a Python-based Dash application.
Inspiration from this Plotly Dash community post.
JavaScript to put into Dash’s “assets” folder:
// Global variable to track reCAPTCHA state
window.recaptchaState = {
loaded: false,
siteKey: null,
action: null,
initialized: false,
};
// Function to initialize invisible reCAPTCHA
async function initializeRecaptcha(data) {
// Store siteKey and action globally
window.recaptchaState.siteKey = data["site_key"];
window.recaptchaState.action = data["action"];
// Load reCAPTCHA script if not already loaded
if (!window.recaptchaState.loaded) {
await new Promise((resolve, reject) => {
console.log("Loading reCAPTCHA script...");
const script = document.createElement("script");
// If the following doesn't render properly (i.e. the MIME type is set to HTML instead of JS)
// then make a new site key using reCAPTCHA v3 (invisible and score-based instead of render-based and clickable)
// Here's the error message if the link below doesn't work properly:
// ERROR: The resource from "https://www.google.com/recaptcha/enterprise.js?render=..."
// was blocked due to MIME type ("text/html") mismatch (X-Content-Type-Options: nosniff
script.src = `https://www.google.com/recaptcha/enterprise.js?render=${window.recaptchaState.siteKey}`;
// script.async = true;
// script.defer = true;
// Ensure the correct MIME type is set so the browser doesn't think it's an HTML file
script.type = "text/javascript";
script.onload = async () => {
console.log("reCAPTCHA script loaded successfully");
window.recaptchaState.loaded = true; // Mark as loaded
resolve();
};
// Handle script load error
script.onerror = (error) => {
reject(new Error("Failed to load reCAPTCHA script"));
};
// Put the script in the head tag
document.head.appendChild(script);
});
}
// Initialize after loading
if (!window.recaptchaState.initialized) {
try {
// Wrap grecaptcha.enterprise.ready() in a Promise
await new Promise((resolve, reject) => {
window.grecaptcha.enterprise.ready(() => {
console.log("reCAPTCHA is ready");
window.recaptchaState.initialized = true;
resolve();
});
});
} catch (error) {
console.error("reCAPTCHA initialization error:", error);
throw error;
}
}
// This is just the children of the div, not the div itself
return "";
}
// Handle form submission and reCAPTCHA verification
async function handleFormSubmit(n_clicks, data) {
try {
// if (!window.recaptchaState.loaded || !window.grecaptcha?.enterprise) {
if (!window.grecaptcha?.enterprise) {
throw new Error("reCAPTCHA not loaded yet");
}
// Wait for reCAPTCHA to be ready
await new Promise((resolve, reject) => {
window.grecaptcha.enterprise.ready(() => {
console.log("reCAPTCHA is ready");
resolve();
});
});
// Execute invisible reCAPTCHA
const token = await window.grecaptcha.enterprise.execute(data["site_key"], {
action: data["action"],
});
console.log("reCAPTCHA token:", token);
return token; // Return the token to the caller
} catch (error) {
console.error("reCAPTCHA error:", error);
throw error;
}
}
// Expose functions to Dash clientside callbacks
window.dash_clientside = Object.assign({}, window.dash_clientside, {
contact_namespace: {
initialize_recaptcha_in_js: initializeRecaptcha,
on_submit_application: handleFormSubmit,
},
});
Contact page layout in Dash:
import os
from typing import List
import dash_bootstrap_components as dbc
import phonenumbers
from dash import (
ClientsideFunction,
Input,
Output,
State,
callback,
clientside_callback,
dcc,
html,
)
from dash.exceptions import PreventUpdate
from flask_login import current_user
def contact_layout():
"""Get the Dash layout for the contact page."""
return dbc.Container(
dbc.Row(
justify="center",
children=dbc.Col(
lg=10,
xl=8,
# xxl=6,
# fluid=True,
children=[
# Hidden signal value, which starts the page-load callbacks
html.Div(id="hidden_signal_contact", style=DISPLAY_NONE),
# Store something neeeded for dynamic JavaScript (e.g. Google RECAPTCHA site key)
dcc.Store(
id="store_site_key_action_contact", storage_type="memory"
),
# Need a dummy place for an Output() to nowhere
dcc.Store(id="store_nothing_contact", storage_type="memory"),
# Store the reCAPTCHA response so we can assess it in the callback
dcc.Store(
id="store_recaptcha_response_contact", storage_type="memory"
),
dbc.Card(
class_name="mt-3",
children=[
dbc.CardHeader(
"Contact IJACK",
),
dbc.CardBody(
[
dbc.Row(
[
dbc.Col(
[
dbc.Label(
"First Name",
html_for="contact_first_name",
class_name="mb-1",
),
dbc.Input(
type="text",
id="contact_first_name",
persistence=True,
),
]
),
dbc.Col(
[
dbc.Label(
"Last Name",
html_for="contact_last_name",
class_name="mb-1",
),
dbc.Input(
type="text",
id="contact_last_name",
persistence=True,
),
]
),
],
class_name="mb-3",
),
dbc.Row(
[
dbc.Col(
[
dbc.Label(
"Email",
html_for="contact_email",
class_name="mb-1",
),
dbc.Input(
type="email",
id="contact_email",
persistence=True,
),
]
),
],
class_name="mb-3",
),
dbc.Row(
[
dbc.Col(
[
dbc.Label(
"Message",
html_for="contact_message",
class_name="mb-1",
),
dbc.Textarea(
id="contact_message",
persistence=True,
# class_name="mb-3",
placeholder="Enter your message here",
),
]
),
],
class_name="mb-4",
),
dbc.Row(
dbc.Col(
dbc.Button(
"Submit",
id="contact_submit_btn",
color="dark",
class_name="mr-1",
),
),
class_name="mb-1",
),
dbc.Row(
dbc.Col(
dbc.FormText(
id="contact_submit_status",
)
),
class_name="mb-1",
),
],
),
],
),
],
),
)
)
The callbacks for storing and processing reCAPTCHA stuff:
from app.recaptcha import recaptcha, RecaptchaResponse
@callback(
Output("store_site_key_action_contact", "data"),
Input("hidden_signal_contact", "children"),
prevent_initial_call=False,
)
def store_google_recaptcha_sitekey(_):
"""Store the reCAPTCHA sitekey in the hidden div"""
return {"site_key": os.getenv("RECAPTCHA_SITE_KEY", ""), "action": "contact"}
# The following clientside_callbacks refer to JavaScript functions in app/assets/recaptcha.js
clientside_callback(
ClientsideFunction(
namespace="contact_namespace",
function_name="initialize_recaptcha_in_js",
),
Output("store_nothing_contact", "data"),
Input("store_site_key_action_contact", "data"),
prevent_initial_call=True,
)
clientside_callback(
ClientsideFunction(
namespace="contact_namespace",
function_name="on_submit_application",
),
Output("store_recaptcha_response_contact", "data"),
Input("contact_submit_btn", "n_clicks"),
State("store_site_key_action_contact", "data"),
prevent_initial_call=True,
)
def recaptcha_is_good(recaptcha_clientside_response: str, expected_action: str) -> bool:
"""For Dash, get the recaptcha response from the client side"""
if (
recaptcha_clientside_response is None
or str(recaptcha_clientside_response).strip() == ""
):
return False
try:
result: RecaptchaResponse = recaptcha.verify_token(
token=recaptcha_clientside_response, expected_action=expected_action
)
current_app.logger.info(f"Recaptcha result: {result}")
return result.success
except Exception:
current_app.logger.exception("Problem with recaptcha response")
return False
@callback(
Output("contact_submit_status", "children"),
Output("contact_submit_status", "color"),
# Input("contact_submit_btn", "n_clicks"),
Input("store_recaptcha_response_contact", "data"),
State("contact_first_name", "value"),
State("contact_last_name", "value"),
State("contact_email", "value"),
State("contact_message", "value"),
# Validation results
State("contact_first_name", "invalid"),
State("contact_last_name", "invalid"),
State("contact_email", "invalid"),
State("contact_message", "invalid"),
prevent_initial_call=True,
)
def process_contact_form(
# contact_submit_btn_n_clicks,
contact_recaptcha_response_children,
contact_first_name_value,
contact_last_name_value,
contact_email_value,
contact_message_value,
# Validation results
contact_first_name_invalid,
contact_last_name_invalid,
contact_email_invalid,
contact_message_invalid,
):
"""Process the contact form"""
if (
# Validation results
contact_first_name_invalid
or contact_last_name_invalid
or contact_email_invalid
or contact_message_invalid
):
msg = "Please fill in all required fields"
color = "danger"
# flash(msg, color)
return msg, color
if not recaptcha_is_good(
contact_recaptcha_response_children, expected_action="contact"
):
msg = "reCAPTCHA verification failed"
color = "danger"
# flash(msg, color)
return msg, color
Finally, the “recaptcha.py” module for the server-side assessment generation:
import os
from dataclasses import dataclass
from typing import Optional
from flask import current_app, request
from google.cloud import recaptchaenterprise_v1
from google.oauth2 import service_account
@dataclass
class RecaptchaResponse:
"""Data class to hold reCAPTCHA verification response"""
success: bool
score: Optional[float] = None
action: Optional[str] = None
error_codes: Optional[list] = None
class RecaptchaEnterprise:
"""Handle Google reCAPTCHA Enterprise verification"""
def __init__(
self,
project_id: str,
site_key: str,
credentials_path: str,
):
"""Initialize reCAPTCHA Enterprise client
Args:
project_id: Google Cloud project ID
site_key: reCAPTCHA site key
credentials_dict: Service account JSON dictionary
credentials_path: Path to service account JSON file
"""
self.project_id = project_id
self.site_key = site_key
if not credentials_path:
raise ValueError("Service account credentials not provided")
# Load credentials from service account file
self.credentials = service_account.Credentials.from_service_account_file(
credentials_path,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
def verify_token(self, token: str, expected_action: str) -> RecaptchaResponse:
"""Verify a reCAPTCHA token
Args:
token: The reCAPTCHA token from client
expected_action: The expected action name to verify
Returns:
RecaptchaResponse with verification results
"""
try:
# Create the reCAPTCHA client
client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient(
credentials=self.credentials
)
# Create the assessment request
event = recaptchaenterprise_v1.Event()
event.site_key = self.site_key
event.token = token
event.expected_action = expected_action
# Add user context if available
if request:
event.user_ip_address = request.remote_addr
event.user_agent = request.headers.get("User-Agent", "")
# Build the assessment
assessment = recaptchaenterprise_v1.Assessment()
assessment.event = event
# Submit the assessment
request_obj = recaptchaenterprise_v1.CreateAssessmentRequest()
request_obj.assessment = assessment
request_obj.parent = f"projects/{self.project_id}"
response = client.create_assessment(request_obj)
# Validate the response
if not response.token_properties.valid:
current_app.logger.error(
f"Invalid token: {response.token_properties.invalid_reason}"
)
return RecaptchaResponse(
success=False,
error_codes=[str(response.token_properties.invalid_reason)],
)
# Verify action matches
if response.token_properties.action != expected_action:
current_app.logger.error(
f"Action mismatch. Expected: {expected_action}, "
f"Got: {response.token_properties.action}"
)
return RecaptchaResponse(success=False, error_codes=["action_mismatch"])
# Return successful response with score
return RecaptchaResponse(
success=True,
score=response.risk_analysis.score,
action=response.token_properties.action,
)
except Exception as e:
current_app.logger.exception("reCAPTCHA verification failed")
return RecaptchaResponse(success=False, error_codes=[str(e)])
# Initialize the global reCAPTCHA instance
recaptcha = RecaptchaEnterprise(
# This should be your Google Cloud project ID, not the reCAPTCHA site key
# project_id=os.getenv("RECAPTCHA_SITE_KEY"),
project_id="my-project",
# This is your reCAPTCHA site key
site_key=os.getenv("RECAPTCHA_SITE_KEY"),
credentials_path=os.getenv("GOOGLE_APPLICATION_CREDENTIALS"),
)
I hope this saves you a little bit of frustration trying to figure out how to add reCAPTCHA Enterprise in your Plotly Dash application.
Cheers,
Sean
Comments