diff --git a/README.md b/README.md index d1afadc..8f4836b 100644 --- a/README.md +++ b/README.md @@ -216,3 +216,16 @@ Things to do in the future: * Sandboxed plugin architecture * Audio CAPTCHA samples * Interactive CAPTCHA samples + +## WordPress Plugin + +A WordPress plugin is included to protect forms on your WordPress site (such as Comments, Login, and Registration). + +### Installation +1. Copy the `plugins/wordpress/librecaptcha.php` file (or the `plugins/wordpress` directory) to your WordPress `wp-content/plugins/` directory. +2. Log into your WordPress admin dashboard and go to **Plugins**. +3. Activate the **LibreCaptcha** plugin. +4. Navigate to **Settings > LibreCaptcha**. +5. Enter the **LibreCaptcha Server URL** (e.g., `http://localhost:8888`). +6. Adjust the JSON configuration and select the forms you wish to protect. +7. You can also use the `[librecaptcha]` shortcode to embed the CAPTCHA in custom pages. diff --git a/plugins/wordpress/librecaptcha.php b/plugins/wordpress/librecaptcha.php new file mode 100644 index 0000000..537b007 --- /dev/null +++ b/plugins/wordpress/librecaptcha.php @@ -0,0 +1,451 @@ + 'application/json' ); + if ( ! empty( $auth_key ) ) { + $headers['Auth'] = $auth_key; + } + + $response = wp_remote_post( $server_url . '/v2/answer', array( + 'headers' => $headers, + 'body' => wp_json_encode( array( + 'id' => $captcha_id, + 'answer' => $captcha_answer, + ) ), + 'method' => 'POST', + 'data_format' => 'body', + ) ); + + if ( is_wp_error( $response ) ) { + return false; // Fail safe or fail secure? typically fail secure for captcha + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( isset( $data['result'] ) && ( $data['result'] === 'True' || $data['result'] === true ) ) { + return true; + } + + return false; + } + + public function verify_login_captcha( $user, $username, $password ) { + // Only check if it's a POST request (login attempt) and user is not already an error + if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ! is_wp_error( $user ) ) { + if ( ! $this->verify_captcha() ) { + return new WP_Error( 'authentication_failed', __( 'ERROR: The CAPTCHA was incorrect.' ) ); + } + } + return $user; + } + + public function verify_registration_captcha( $errors, $sanitized_user_login, $user_email ) { + if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { + if ( ! $this->verify_captcha() ) { + $errors->add( 'captcha_failed', __( 'ERROR: The CAPTCHA was incorrect.' ) ); + } + } + return $errors; + } + + public function verify_comment_captcha( $comment_post_id ) { + // If user is logged in, they might not see the captcha depending on form implementation, + // but since we hooked `comment_form_logged_in_after`, they do see it. + if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { + if ( ! $this->verify_captcha() ) { + wp_die( __( 'ERROR: The CAPTCHA was incorrect. Please go back and try again.' ) ); + } + } + } + + public function ajax_test_load() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized' ); + } + + $server_url = rtrim( get_option( 'lc_server_url', '' ), '/' ); + $auth_key = get_option( 'lc_auth_key', '' ); + $config_json_string = get_option( 'lc_config_json', '{"level":"easy","media":"image/png","input_type":"text","size":"350x100"}' ); + + if ( empty( $server_url ) ) { + wp_send_json_error( 'Server URL is not configured.' ); + } + + $headers = array( 'Content-Type' => 'application/json' ); + if ( ! empty( $auth_key ) ) { + $headers['Auth'] = $auth_key; + } + + $response = wp_remote_post( $server_url . '/v2/captcha', array( + 'headers' => $headers, + 'body' => $config_json_string, + 'method' => 'POST', + 'data_format' => 'body', + ) ); + + if ( is_wp_error( $response ) ) { + wp_send_json_error( 'Failed to connect to LibreCaptcha server.' ); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( isset( $data['id'] ) ) { + // Also return the full server URL so the client can load the image + wp_send_json_success( array( 'id' => $data['id'], 'server_url' => $server_url ) ); + } else { + wp_send_json_error( 'Invalid response from LibreCaptcha server.' ); + } + } + + public function ajax_test_check() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized' ); + } + + $captcha_id = sanitize_text_field( $_POST['captcha_id'] ?? '' ); + $captcha_answer = sanitize_text_field( $_POST['captcha_answer'] ?? '' ); + + if ( empty( $captcha_id ) || empty( $captcha_answer ) ) { + wp_send_json_error( 'Missing ID or Answer.' ); + } + + $server_url = rtrim( get_option( 'lc_server_url', '' ), '/' ); + $auth_key = get_option( 'lc_auth_key', '' ); + + $headers = array( 'Content-Type' => 'application/json' ); + if ( ! empty( $auth_key ) ) { + $headers['Auth'] = $auth_key; + } + + $response = wp_remote_post( $server_url . '/v2/answer', array( + 'headers' => $headers, + 'body' => wp_json_encode( array( + 'id' => $captcha_id, + 'answer' => $captcha_answer, + ) ), + 'method' => 'POST', + 'data_format' => 'body', + ) ); + + if ( is_wp_error( $response ) ) { + wp_send_json_error( 'Failed to connect to LibreCaptcha server.' ); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + wp_send_json_success( $data ); + } + + public function render_captcha_shortcode() { + ob_start(); + $this->render_captcha(); + return ob_get_clean(); + } + + public function render_captcha() { + $server_url = get_option( 'lc_server_url', '' ); + if ( empty( $server_url ) ) { + return; + } + + $server_url = rtrim( $server_url, '/' ); + $auth_key = get_option( 'lc_auth_key', '' ); + $config_json_string = get_option( 'lc_config_json', '{"level":"easy","media":"image/png","input_type":"text","size":"350x100"}' ); + + $headers = array( 'Content-Type' => 'application/json' ); + if ( ! empty( $auth_key ) ) { + $headers['Auth'] = $auth_key; + } + + $response = wp_remote_post( $server_url . '/v2/captcha', array( + 'headers' => $headers, + 'body' => $config_json_string, + 'method' => 'POST', + 'data_format' => 'body', + ) ); + + if ( is_wp_error( $response ) ) { + echo '
Error connecting to LibreCaptcha server.
'; + return; + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + if ( ! isset( $data['id'] ) ) { + echo '
Error loading CAPTCHA. Invalid response.
'; + return; + } + + $captcha_id = esc_attr( $data['id'] ); + $media_url = esc_url( $server_url . '/v2/media?id=' . $captcha_id ); + + ?> +
+
+ CAPTCHA +
+ + +
+ +
+

LibreCaptcha Settings

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
LibreCaptcha Server URL + +

The URL to your LibreCaptcha instance (e.g. http://localhost:8888). Leave empty to disable.

+
Auth Key (Optional) + +

Optional auth key if your server requires it.

+
Config JSON + +

JSON configuration for the CAPTCHA requests.

+
Enable on Login Form + /> +
Enable on Registration Form + /> +
Enable on Comment Form + /> +
+ +
+ +
+

Test LibreCaptcha Connection

+

Use this section to verify your server configuration and ensure CAPTCHAs are loading and validating correctly.

+
+ +
+ + +
+ + +
+ > handlersByPath = new java.util.HashMap<>(); for (final var handler : handlers) { - // System.out.println("Registering handler for " + handler.path); + handlersByPath.computeIfAbsent(handler.path, k -> new java.util.ArrayList<>()).add(handler); + } + + for (final var entry : handlersByPath.entrySet()) { + final String path = entry.getKey(); + final java.util.List pathHandlers = entry.getValue(); + // System.out.println("Registering handler for " + path); this.server.createContext( - handler.path, + path, new HttpHandler() { public void handle(final HttpExchange exchange) { final var method = exchange.getRequestMethod(); - final Response errorResponse = checkMethods(handler.methods, method); - try (final var os = exchange.getResponseBody()) { - Response response; - if (errorResponse != null) { - response = errorResponse; - } else { + + Handler matchingHandler = null; + for (Handler h : pathHandlers) { + if (h.methods.length == 0 || java.util.Arrays.asList(h.methods).contains(method)) { + matchingHandler = h; + break; + } + } + + Response response; + if (matchingHandler == null) { + // Collect all allowed methods + java.util.List allowedMethods = new java.util.ArrayList<>(); + for (Handler h : pathHandlers) { + allowedMethods.addAll(java.util.Arrays.asList(h.methods)); + } + java.util.Map> allowHeader = new java.util.HashMap<>(); + allowHeader.put("Allow", java.util.Collections.singletonList(String.join(", ", allowedMethods))); + response = new StringResponse(405, "Method Not Allowed", allowHeader); + } else { try { - response = handler.processor.process(new Request(exchange)); + response = matchingHandler.processor.process(new Request(exchange)); } catch (final Exception e) { e.printStackTrace(); response = new StringResponse(500, "Error: " + e); } - } + } + try (final var os = exchange.getResponseBody()) { final var headersToSend = response.getResponseHeaders(); if (headersToSend != null) { final var responseHeaders = exchange.getResponseHeaders(); @@ -256,6 +280,11 @@ public ServerBuilder handle(final Handler handler) { return this; } + public ServerBuilder OPTIONS(final String path, final Processor processor) { + handlers.add(new Handler(path, "OPTIONS", request -> processor.process(request))); + return this; + } + public ServerBuilder GET(final String path, final Processor processor) { handlers.add(new Handler(path, "GET", request -> processor.process(request))); return this; diff --git a/src/main/scala/lc/server/Server.scala b/src/main/scala/lc/server/Server.scala index 83f0d4b..14ec63a 100644 --- a/src/main/scala/lc/server/Server.scala +++ b/src/main/scala/lc/server/Server.scala @@ -41,10 +41,21 @@ class Server( false } + private def getOptionsResponse(): StringResponse = { + val optionsHeaderMap = new java.util.HashMap[String, java.util.List[String]]() + if (corsHeader.nonEmpty) { + optionsHeaderMap.put("Access-Control-Allow-Origin", List(corsHeader).asJava) + } + optionsHeaderMap.put("Access-Control-Allow-Methods", List("POST, GET, OPTIONS").asJava) + optionsHeaderMap.put("Access-Control-Allow-Headers", List("Content-Type, Auth").asJava) + new StringResponse(200, "", optionsHeaderMap) + } + val serverBuilder: ServerBuilder = picoserve.Server .builder() .address(new InetSocketAddress(address, port)) .backlog(32) + .OPTIONS("/v2/captcha", (_) => getOptionsResponse()) .POST( "/v2/captcha", (request) => { @@ -63,6 +74,7 @@ class Server( } } ) + .OPTIONS("/v2/media", (_) => getOptionsResponse()) .GET( "/v2/media", (request) => { @@ -81,6 +93,7 @@ class Server( } } ) + .OPTIONS("/v2/answer", (_) => getOptionsResponse()) .POST( "/v2/answer", (request) => {