altcha-org / altcha

GDPR compliant, self-hosted CAPTCHA alternative with PoW mechanism and advanced anti-spam filter.
https://altcha.org
MIT License
493 stars 16 forks source link

Expires challenge calls continue to renew on page change with #35

Closed teolaz closed 5 months ago

teolaz commented 5 months ago

Hello, Matteo from Italy here! Thank you for your great job with this alternative. I needed to switch on this because of new GDPR restrictions for cookies on reCaptcha.

I'm trying to integrate your WebComponent in a Gatsby site (React Headless) with Formik forms. I've already managed to make the entire process of challenge and validation works, without any big issue. As Gatsby is using React, what I needed to do for the "statechange" event handling was to approach my form field component using the "useEffect" hook, so I was able to add the event listener on component mount and remove it on component unmount like below. For simplify your reading, if you don't know that, when you return a method in useEffect, that method is fired when the component unmount.

import { Field } from "formik";
import React, { useEffect, useRef } from "react";

export default function Altcha({ props }) {
  const widgetRef = useRef(null);
  const eventListenerElements = useRef(new WeakSet());

  /* 
   need to use this as I need to append event listener on component creation
   and also on component destruction
   */
  useEffect(() => {
    const handleStateChange = (ev) => {
      // state can be: unverified, verifying, verified, error
      if (ev.detail.state === "verified") {
        // payload contains base64 encoded data for the server
        props.setFieldValue("altcha", ev.detail.payload);
      } else {
        props.setFieldValue("altcha", "");
      }
    };

    const widgetElement = widgetRef.current;

    if (widgetElement && !eventListenerElements.current.has(widgetElement)) {
      widgetElement.addEventListener("statechange", handleStateChange);
      eventListenerElements.current.add(widgetElement);
    }

    // Cleanup event listener and mutation observer on component unmount
    return () => {
      if (widgetElement) {
        widgetElement.removeEventListener("statechange", handleStateChange);
        eventListenerElements.current.delete(widgetElement);
      }
    };
  }, []);

  return (
    <div className="field-container">
      <div className="info">Accept the captcha to continue*</div>
      <Field type="hidden" name="altcha" value="" />
      <altcha-widget
        ref={widgetRef}
        challengeurl={"/.netlify/functions/altcha-challenge"}
      />
      {props.errors.altcha && props.touched.altcha ? (
        <div className="error">
          <span>{props.errors.altcha}</span>
        </div>
      ) : null}
    </div>
  );
}

this is my challenge handler that answer the url for the json challenge:

import { createChallenge } from "altcha-lib";

export default async function handler(req, context) {
  const hmacKey = process.env.HMAC_KEY;
  try {
    // set expiration time 1 minute max
    const expirationDate = new Date();
    expirationDate.setMinutes(expirationDate.getMinutes() + 1);

    // Create a new challenge and send it to the client:
    const challenge = await createChallenge({
      number: 10000,
      expires: expirationDate,
      hmacKey,
    });
    return Response.json(challenge);
  } catch (err) {
    console.log(err);
  }
}

I repeat, everything is working correctly. Still, I have a small issue and I believe the problem it's in your component, or in its lifecycle more precisely, maybe because you planned the event handling to respect a normal page change, so when someone change page pressing a link the event handler stops. Instead, it seems that when the expirationDate fire a challenge call because it wants to reload the payload, even if I later change the page (with Gatsby you're using Browser History and React Router, not the entire DOM reload), so the widget has been removed from DOM, I'm continuing to see challenge request calls from the frontend, as the widget was removed but not the event handler.

Simulating a dumb user, if the user:

what I see from the browser network section is a waterfall of unexplicable calls.

image

I don't think this is optimized for production deployments, as I'm loading both the server(s) and client(s) with useless calls and job to do.

What I'm imagining to solve the problem (without checking your code deeper) is to remove the event handlers when the component has been removed from DOM. I've searched through the net to find if something like this is possible and I've found something that seems to be quite similar to my case https://hackernoon.com/a-guide-to-handling-web-component-removal-with-disconnectedcallback

Or maybe I'm missing something?

I hope to have been clear enough, in case tell me if you need further info. Thank you Teo

ovx commented 5 months ago

Hi, thanks for reporting. The component didn't properly cleared the expiration timer, it's now fixed in 0.4.2. Would be great if you could retest with this version and report if it solved your issues.

teolaz commented 5 months ago

Ehi @ovx, thank you so much for your responsiveness! I just tried your fix and it seems now everything's ok. Thank you! Teo