diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example index ec87c9804d..88c3402257 100644 --- a/Open-ILS/examples/opensrf.xml.example +++ b/Open-ILS/examples/opensrf.xml.example @@ -1491,6 +1491,24 @@ vim:et:ts=4:sw=4: + + + 3 + 1 + perl + OpenILS::Application::Recaptcha + 100 + + biblio.recaptcha.log + biblio.recaptcha.sock + biblio.recaptcha.pid + 2 + 10 + 1 + 3 + + + @@ -1543,6 +1561,7 @@ vim:et:ts=4:sw=4: open-ils.curbside open-ils.geo open-ils.sip2 + biblio.recaptcha diff --git a/Open-ILS/examples/opensrf_core.xml.example b/Open-ILS/examples/opensrf_core.xml.example index 943ad8b290..dbdd5f5daf 100644 --- a/Open-ILS/examples/opensrf_core.xml.example +++ b/Open-ILS/examples/opensrf_core.xml.example @@ -42,6 +42,7 @@ Example OpenSRF bootstrap configuration file for Evergreen open-ils.serial open-ils.ebook_api open-ils.sip2 + biblio.recaptcha @@ -110,6 +111,7 @@ Example OpenSRF bootstrap configuration file for Evergreen open-ils.auth_mfa open-ils.collections open-ils.reporter + biblio.recaptcha diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Recaptcha.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Recaptcha.pm new file mode 100644 index 0000000000..e450e5cfc0 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Recaptcha.pm @@ -0,0 +1,119 @@ +# ------------------------------------------------------------------------------- +# Module: OpenILS::Application::Recaptcha +# Author: Ian Skelskey +# Organization: Bibliomation, Inc. +# Year: 2024 +# Description: Implements reCAPTCHA v3 verification within Evergreen ILS as an +# OpenSRF service, using Google's API for user input validation. +# ------------------------------------------------------------------------------- + +package OpenILS::Application::Recaptcha; + +use strict; +use warnings; +use base 'OpenSRF::Application'; +use LWP::UserAgent; +use JSON; +use OpenSRF::Utils::Logger qw($logger); +use OpenILS::Utils::CStoreEditor; + +# Register OpenSRF method +__PACKAGE__->register_method( + method => 'recaptcha_verify', + api_name => 'biblio.recaptcha.verify', + argc => 1, # Expects a single argument (hash with token and org_unit) + stream => 0 # Non-streaming method +); + +# Singleton for CStore Editor +my $editor; +sub editor { + return $editor ||= OpenILS::Utils::CStoreEditor->new; +} + +# Retrieve the reCAPTCHA secret key +sub get_secret_key { + my ($org) = @_; + + my $U = 'OpenILS::Application::AppUtils'; + my $secret_key = $U->ou_ancestor_setting_value($org, 'recaptcha.secret_key'); + + if ($secret_key) { + $logger->info("Retrieved reCAPTCHA secret key for org ID: $org"); + return $secret_key; + } + + $logger->error("No reCAPTCHA secret key found for org ID: $org"); + return undef; +} + +# Main method to verify reCAPTCHA token +sub recaptcha_verify { + my ($self, $client, $args) = @_; + + my $token = $args->{token}; + my $org_unit = $args->{org_unit} || 1; # Default to consortium (org_unit 1) + + $logger->info("Starting reCAPTCHA verification for org_unit: $org_unit"); + $logger->info("Received reCAPTCHA token"); + + my $response = send_recaptcha_request($token, $org_unit); + return encode_json(process_recaptcha_response($response)); +} + +# Send request to Google's reCAPTCHA API +sub send_recaptcha_request { + my ($token, $org) = @_; + + my $secret = get_secret_key($org); + unless ($secret) { + $logger->error("Missing reCAPTCHA secret key for org ID: $org"); + return { success => 0, error => 'Missing reCAPTCHA secret key' }; + } + + my $url = 'https://www.google.com/recaptcha/api/siteverify'; + my $ua = LWP::UserAgent->new(timeout => 10); + my $response = $ua->post($url, { + secret => $secret, + response => $token + }); + + if (!$response->is_success) { + $logger->error("Failed to reach Google reCAPTCHA server: " . $response->status_line); + return { success => 0, error => 'Unable to reach reCAPTCHA server' }; + } + + $logger->info("Successfully sent reCAPTCHA verification request"); + return $response; +} + +# Process the response from Google's reCAPTCHA API +sub process_recaptcha_response { + my ($response) = @_; + + my $content = $response->decoded_content || '{}'; + my $json; + eval { $json = decode_json($content); }; + if ($@) { + $logger->error("Error parsing JSON from Google: $@"); + return { success => 0, error => 'Invalid JSON response' }; + } + + $logger->info("Decoded reCAPTCHA response: $content"); + + unless ($json->{success}) { + my $errors = join(", ", @{$json->{'error-codes'} || []}); + $logger->error("reCAPTCHA verification failed: $errors"); + return { success => 0, 'error-codes' => $json->{'error-codes'} }; + } + + if ($json->{score} < 0.5) { + $logger->warn("Low reCAPTCHA score: " . $json->{score}); + return { success => 0, error => 'Low reCAPTCHA score' }; + } + + $logger->info("reCAPTCHA successfully verified with score: " . $json->{score}); + return { success => 1 }; +} + +1; diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/login/form.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/login/form.tt2 index 94a26507ac..9e4a459017 100755 --- a/Open-ILS/src/templates-bootstrap/opac/parts/login/form.tt2 +++ b/Open-ILS/src/templates-bootstrap/opac/parts/login/form.tt2 @@ -67,3 +67,8 @@ +[% INCLUDE "opac/parts/recaptcha.tt2" + action_name="opac_login" + submit_action="submit" + target_element_id="login_form" +%] \ No newline at end of file diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/login/login_modal.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/login/login_modal.tt2 index ca8c40d8fd..b572e70b9c 100755 --- a/Open-ILS/src/templates-bootstrap/opac/parts/login/login_modal.tt2 +++ b/Open-ILS/src/templates-bootstrap/opac/parts/login/login_modal.tt2 @@ -78,3 +78,8 @@ +[% INCLUDE "opac/parts/recaptcha.tt2" + action_name="opac_login" + submit_action="submit" + target_element_id="login_form" +%] \ No newline at end of file diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/recaptcha.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/recaptcha.tt2 new file mode 100644 index 0000000000..038c194d67 --- /dev/null +++ b/Open-ILS/src/templates-bootstrap/opac/parts/recaptcha.tt2 @@ -0,0 +1,28 @@ +[% + org_unit = ctx.search_ou; + recaptcha_site_key = ctx.get_org_setting(org_unit, 'recaptcha.site_key'); + action_name = action_name || 'register'; # Default action name if none is provided + submit_action = submit_action || 'submit'; # Default submit action if none is provided + target_element_id = target_element_id || 'recaptcha-form'; # Default target element ID + recaptcha_enabled = ctx.get_org_setting(ctx.search_ou, 'recaptcha.enabled'); +%] +[% IF recaptcha_enabled && recaptcha_enabled == 1 %] + + + + + + + + +[% ELSE %] + +[% END %] \ No newline at end of file diff --git a/Open-ILS/src/templates-bootstrap/opac/parts/searchbar.tt2 b/Open-ILS/src/templates-bootstrap/opac/parts/searchbar.tt2 index d28701eaf5..f814b7a309 100755 --- a/Open-ILS/src/templates-bootstrap/opac/parts/searchbar.tt2 +++ b/Open-ILS/src/templates-bootstrap/opac/parts/searchbar.tt2 @@ -31,7 +31,7 @@ END;
[% UNLESS took_care_of_form -%] -
+ [%- END %] [% IF ctx.page == 'rresult' && ctx.metarecord && search.metarecord_default %] @@ -110,17 +110,22 @@ END;
- - + [%- IF ctx.depth_sel_button AND NOT took_care_of_form %] - + [%- END %]
+ [% INCLUDE "opac/parts/recaptcha.tt2" + action_name="opac_search" + submit_action="submit" + target_element_id="opac-search-form" + %] + [% IF ctx.bookbag %]
- - +
\ No newline at end of file diff --git a/Open-ILS/src/templates-bootstrap/opac/register.tt2 b/Open-ILS/src/templates-bootstrap/opac/register.tt2 index 6ce95c0012..7d2777a0a3 100755 --- a/Open-ILS/src/templates-bootstrap/opac/register.tt2 +++ b/Open-ILS/src/templates-bootstrap/opac/register.tt2 @@ -95,7 +95,7 @@ END; ) | html %] [% END %] - +
@@ -105,7 +105,7 @@ END; can_have_users_only=1 valid_org_list=ctx.register.valid_orgs %] -
+
[% IF ctx.register.invalid.bad_home_ou %] @@ -284,6 +284,7 @@ FOR field_def IN register_fields; [%- END %] +[% INCLUDE "opac/parts/recaptcha.tt2" + action_name="register" + submit_action="submit" + target_element_id="registration-form" +%] diff --git a/Open-ILS/src/templates/opac/parts/searchbar.tt2 b/Open-ILS/src/templates/opac/parts/searchbar.tt2 index 8d454f45f1..bb1a2b7cca 100644 --- a/Open-ILS/src/templates/opac/parts/searchbar.tt2 +++ b/Open-ILS/src/templates/opac/parts/searchbar.tt2 @@ -31,7 +31,7 @@ END;
[% UNLESS took_care_of_form -%] - + [%- END %] [% IF ctx.page == 'rresult' && ctx.metarecord && search.metarecord_default %] @@ -229,3 +229,8 @@ END; -->
+[% INCLUDE "opac/parts/recaptcha.tt2" + action_name="opac_search" + submit_action="submit" + target_element_id="opac-search-form" +%] diff --git a/Open-ILS/web/opac/common/js/recaptcha.js b/Open-ILS/web/opac/common/js/recaptcha.js new file mode 100644 index 0000000000..0597e2e476 --- /dev/null +++ b/Open-ILS/web/opac/common/js/recaptcha.js @@ -0,0 +1,71 @@ +document.addEventListener('DOMContentLoaded', () => { + function initializeRecaptcha(formId, siteKey, actionName, submitAction) { + console.log('Initializing reCAPTCHA for form:', formId); + const form = document.getElementById(formId); + if (!form) { + console.log('Form not found:', formId); + return; + } + + // Create and append reCAPTCHA container dynamically + const recaptchaContainer = createRecaptchaContainer(); + form.appendChild(recaptchaContainer); + console.log('reCAPTCHA container appended to form:', formId); + + // Add event listener to the form for reCAPTCHA validation + form.addEventListener('submit', event => { + event.preventDefault(); + console.log('Form submit action intercepted:', submitAction); + grecaptcha.ready(() => { + console.log('Executing reCAPTCHA with siteKey:', siteKey, 'and actionName:', actionName); + grecaptcha.execute(siteKey, { action: actionName }) + .then(token => handleRecaptchaToken(token, form)); + }); + }); + + function createRecaptchaContainer() { + const container = document.createElement('div'); + container.id = 'recaptcha-container'; + console.log('Created reCAPTCHA container'); + return container; + } + + function handleRecaptchaToken(token, form) { + console.log('Received reCAPTCHA token:', token); + console.log('Sending reCAPTCHA verification request...'); + const session = new OpenSRF.ClientSession('biblio.recaptcha'); + const request = session.request('biblio.recaptcha.verify', { + token, + org_unit: form.dataset.orgUnit + }); + + request.oncomplete = response => processRecaptchaResponse(response, form); + request.send(); + console.log('reCAPTCHA verification request sent'); + } + + function processRecaptchaResponse(response, form) { + console.log('Processing reCAPTCHA response...'); + let msg; + while ((msg = response.recv())) { + try { + const responseContent = JSON.parse(msg.content()); + console.log('reCAPTCHA response:', responseContent); + if (responseContent.success === 1) { + console.log('reCAPTCHA validation successful'); + form.submit(); + } else { + console.log('reCAPTCHA validation failed'); + alert('reCAPTCHA validation failed. Please try again.'); + } + } catch (error) { + console.error('Error parsing reCAPTCHA response as JSON:', error); + alert('Error in reCAPTCHA validation. Please try again.'); + } + } + } + } + + // Export the function to be used in other scripts + window.initializeRecaptcha = initializeRecaptcha; +}); \ No newline at end of file diff --git a/docs/gulpfile.js b/docs/gulpfile.js new file mode 100644 index 0000000000..85b885e5e7 --- /dev/null +++ b/docs/gulpfile.js @@ -0,0 +1,35 @@ +'use strict'; + +const gulp = require('gulp'); +const connect = require('gulp-connect'); +const { exec } = require('child_process'); + +const buildDir = 'output'; + +// Task to build Antora documentation +gulp.task('build', (done) => { + exec('antora site-working.yml', (err, stdout, stderr) => { + console.log(stdout); + console.error(stderr); + done(err); + }); +}); + +// Task to serve files with live reload +gulp.task('serve', (done) => { + connect.server({ + root: buildDir, + livereload: true, + port: 8080 + }); + done(); +}); + +// Watch for changes and rebuild +gulp.task('watch', (done) => { + gulp.watch(['**/*.adoc', 'site-working.yml'], gulp.series('build')); + done(); +}); + +// Default task +gulp.task('preview', gulp.series('build', gulp.parallel('serve', 'watch'))); \ No newline at end of file diff --git a/docs/modules/admin/pages/recaptcha.adoc b/docs/modules/admin/pages/recaptcha.adoc new file mode 100644 index 0000000000..a3460ac0db --- /dev/null +++ b/docs/modules/admin/pages/recaptcha.adoc @@ -0,0 +1,200 @@ += Google reCAPTCHA v3 Integration = +:toc: + +== Overview == + +This document describes the integration of Google reCAPTCHA v3 into the Evergreen ILS system. The reCAPTCHA service helps protect the system from spam and abuse by verifying that user interactions are performed by humans. + +== Enabling reCAPTCHA v3 == + +To enable reCAPTCHA v3, follow these steps: + +1. **Obtain reCAPTCHA Keys**: + - Register your site with Google reCAPTCHA to obtain the site key and secret key. +- Visit the link:https://www.google.com/recaptcha/admin[Google reCAPTCHA Admin Console^] to register your site. + +2. **Configure reCAPTCHA in Evergreen**: + - Add the reCAPTCHA keys to your organizational settings in Evergreen. + - Update the `opensrf.xml` and `opensrf_core.xml` configuration files to include the reCAPTCHA service. + +3. **Modify Templates**: + - Update the relevant template files to include reCAPTCHA validation. + +== Configuration Changes == + +The following changes were made to integrate reCAPTCHA v3: + +=== `opensrf.xml` === + +Added the reCAPTCHA service configuration: +```xml + + 3 + 1 + perl + OpenILS::Application::Recaptcha + 100 + + biblio.recaptcha.log + biblio.recaptcha.sock + biblio.recaptcha.pid + 2 + 10 + 1 + 3 + + +``` + +=== `opensrf_core.xml` === + +Added the reCAPTCHA service to the list of services: + +```xml +biblio.recaptcha +``` + +== Adding Library Settings == + +To enable reCAPTCHA v3, you need to add the following library settings to Evergreen: + +* `recaptcha.site_key`: The site key obtained from Google reCAPTCHA. +* `recaptcha.secret_key`: The secret key obtained from Google reCAPTCHA. +* `recaptcha.enabled`: A flag to enable or disable reCAPTCHA (set to true to enable). + +These settings should be applied at the consortium level and will automatically apply to all children. However, supplying different keys for different organizational units will override the consortial setting by default. + +Here are the SQL insert statements to create these library settings: + +```sql +-- Insert reCAPTCHA site key setting type +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) VALUES +('recaptcha.site_key', 'reCAPTCHA site key', 'sec', 'The site key obtained from Google reCAPTCHA.', 'string'); + +-- Insert reCAPTCHA secret key setting type +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) VALUES +('recaptcha.secret_key', 'reCAPTCHA secret key', 'sec', 'The secret key obtained from Google reCAPTCHA.', 'string'); + +-- Insert reCAPTCHA enabled setting type +INSERT INTO config.org_unit_setting_type (name, label, grp, description, datatype) VALUES +('recaptcha.enabled', 'Enable reCAPTCHA', 'sec', 'A flag to enable or disable reCAPTCHA (set to true to enable).', 'bool'); + +-- Insert reCAPTCHA site key setting +INSERT INTO actor.org_unit_setting (org_unit, name, value) VALUES +(1, 'recaptcha.site_key', '""'); + +-- Insert reCAPTCHA secret key setting +INSERT INTO actor.org_unit_setting (org_unit, name, value) VALUES +(1, 'recaptcha.secret_key', '""'); + +-- Insert reCAPTCHA enabled setting +INSERT INTO actor.org_unit_setting (org_unit, name, value) VALUES +(1, 'recaptcha.enabled', 'true'); +``` + +== Template Modifications == + +The following template files were modified to include reCAPTCHA validation: + +=== recaptcha.tt2 === + +A new template file was created to handle reCAPTCHA validation: + +```tt2 + +[% + org_unit = ctx.search_ou; + recaptcha_site_key = ctx.get_org_setting(org_unit, 'recaptcha.site_key'); + action_name = action_name || 'register'; + submit_action = submit_action || 'submit'; + target_element_id = target_element_id || 'recaptcha-form'; + recaptcha_enabled = ctx.get_org_setting(ctx.search_ou, 'recaptcha.enable'); +%] +[% IF recaptcha_enabled && recaptcha_enabled == 1 %] + + + + + + +[% ELSE %] + +[% END %] +``` + +=== register.tt2 === + +Included the reCAPTCHA template: + +```tt2 +[% INCLUDE "opac/parts/recaptcha.tt2" + action_name="register" + submit_action="submit" + target_element_id="registration-form" +%] +``` + +== Conclusion == + +The integration of Google reCAPTCHA v3 into Evergreen ILS enhances security by verifying user interactions. Follow the steps outlined above to enable and configure reCAPTCHA for your Evergreen instance. \ No newline at end of file diff --git a/docs/modules/local_admin/nav.adoc b/docs/modules/local_admin/nav.adoc index 414cce2c8d..e568e77a70 100644 --- a/docs/modules/local_admin/nav.adoc +++ b/docs/modules/local_admin/nav.adoc @@ -18,3 +18,4 @@ ** xref:local_admin:staff_portal_page.adoc[Staff Portal Page] ** xref:local_admin:transit_list.adoc[Transit List] ** xref:admin:lsa-work_log.adoc[Work Log] +** xref:admin:recaptcha.adoc[reCAPTCHA] diff --git a/docs/package.json b/docs/package.json index 7a6eb77857..d08f57bdc3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,8 @@ { "dependencies": { "@antora/lunr-extension": "^1.0.0-alpha.8", - "antora": "^3.1.7" + "antora": "^3.1.7", + "gulp": "^5.0.0", + "gulp-connect": "^5.7.0" } } diff --git a/docs/site-working.yml b/docs/site-working.yml new file mode 100644 index 0000000000..266c55748b --- /dev/null +++ b/docs/site-working.yml @@ -0,0 +1,20 @@ +site: + title: Evergreen Documentation + start_page: docs:shared:about_this_documentation.adoc + url: http://example.com +content: + sources: + - url: ../ +# - url: git://git.evergreen-ils.org/Evergreen.git + start_path: docs +ui: + bundle: + url: https://github.com/IanSkelskey/eg-antora/releases/latest/download/ui-bundle.zip + +output: + dir: ./output + +antora: + extensions: + - require: '@antora/lunr-extension' + index_latest_only: true