Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When clicking outside the reCAPTCHA modal, nothing happens #125

Open
rmarmitt opened this issue May 30, 2023 · 3 comments
Open

When clicking outside the reCAPTCHA modal, nothing happens #125

rmarmitt opened this issue May 30, 2023 · 3 comments

Comments

@rmarmitt
Copy link

In v2, when the challenge is displayed, if the user clicks outside the modal, nothing happens and code execution is interrupted indefinitely.

Any suggestion is welcome.

@Marian-Calin-Stroescu-Fiskars

Currently facing the exact same issue. Any solution to this?

@Coinhexa
Copy link

Coinhexa commented Jan 24, 2024

I confirm the same problem on my end, isnt recaptcha-expired event supposed to fire in some time? I think this might be the problem everyone is referring to

@Coinhexa
Copy link

Coinhexa commented Nov 15, 2024

<div style="visibility: hidden; position: absolute; width: 100%; top: -10000px; left: 0px; right: 0px; transition: visibility linear 0.3s, opacity 0.3s linear; opacity: 0;">
  <div style="width: 100%; height: 100%; position: fixed; top: 0px; left: 0px; z-index: 2000000000; background-color: rgb(255, 255, 255); opacity: 0.5;"></div>
  <div style="margin: 0px auto; top: 0px; left: 0px; right: 0px; position: fixed; border: 1px solid rgb(204, 204, 204); z-index: 2000000000; background-color: rgb(255, 255, 255);">
    <iframe title="recaptcha challenge expires in two minutes" name="c-rus8hqvgje9t" frameborder="0" scrolling="no" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation allow-modals allow-popups-to-escape-sandbox allow-storage-access-by-user-activation" src="https://www.recaptcha.net/recaptcha/api2/bframe?hl=en&amp;v=-ZG7BC9TxCVEbzIO2m429usb&amp;k=6LdRSoQcAAAAAK1WNuqoDKjDrj3ql-V-uxndJsXG" style="width: 100%; height: 100%;"></iframe>
  </div>
</div>
  • When the modal opens it changes to
<div style="visibility: visible; position: absolute; width: 1042px; top: 241.5px; left: 0px; right: 0px; transition: visibility linear, opacity 0.3s linear; opacity: 1;">
  <div style="width: 100%; height: 100%; position: fixed; top: 0px; left: 0px; z-index: 2000000000; background-color: rgb(255, 255, 255); opacity: 0.5;"></div>
  <div style="margin: 0px auto; top: 0px; left: 0px; right: 0px; position: fixed; border: 1px solid rgb(204, 204, 204); z-index: 2000000000; background-color: rgb(255, 255, 255); width: 400px; height: 580px;">
    <iframe title="recaptcha challenge expires in two minutes" name="c-rus8hqvgje9t" frameborder="0" scrolling="no" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation allow-modals allow-popups-to-escape-sandbox allow-storage-access-by-user-activation" src="https://www.recaptcha.net/recaptcha/api2/bframe?hl=en&amp;v=-ZG7BC9TxCVEbzIO2m429usb&amp;k=6LdRSoQcAAAAAK1WNuqoDKjDrj3ql-V-uxndJsXG" style="width: 400px; height: 580px;"></iframe>
  </div>
</div>
  • The trick lies in understanding how the iframe is loaded
  • Basically inside the init() function we need a mutation observer
  • Add this observer and let it observe iframes with the title 'recaptcha challenge expires in two minutes' which is shown by that modal
  • Add a click event for it so that it fires a recaptcha-error event when clicked outside
  • Remove the observer in destroy
  • The code below still has one issue that I am trying to fix. Open that modal, don't move your mouse for like 5 mins and you ll see the modal close automatically. When this happens, the recaptcha-expired event is not fired. Looking into finding a way to fix that
import { EventEmitter } from 'events'
import Vue from 'vue'

const API_URL = 'https://www.recaptcha.net/recaptcha/api.js'

class ReCaptcha {
  constructor({ hideBadge, language, mode, siteKey, version, size }) {
    if (!siteKey) {
      throw new Error('ReCaptcha error: No key provided')
    }

    if (!version) {
      throw new Error('ReCaptcha error: No version provided')
    }

    this._elements = {}
    this._grecaptcha = null

    this._eventBus = null
    this._ready = false

    this.hideBadge = hideBadge
    this.language = language

    this.siteKey = siteKey
    this.version = version
    this.size = size

    this.mode = mode

    this.observer = null
  }

  destroy() {
    if (this._ready) {
      this._ready = false

      const { head } = document
      const { style } = this._elements

      const scripts = [...document.head.querySelectorAll('script')].filter(
        (script) => script.src.includes('recaptcha'),
      )

      if (scripts.length) {
        scripts.forEach((script) => head.removeChild(script))
      }

      if (head.contains(style)) {
        head.removeChild(style)
      }

      const badge = document.querySelector('.grecaptcha-badge')
      if (badge) {
        badge.remove()
      }
    }
    this.stopObservingRecaptchaModal()
  }

  async execute(action) {
    try {
      await this.init()

      if ('grecaptcha' in window) {
        return this._grecaptcha.execute(this.siteKey, { action })
      }
    } catch (error) {
      throw new Error(`ReCaptcha error: Failed to execute ${error}`)
    }
  }

  getResponse(widgetId) {
    return new Promise((resolve, reject) => {
      if ('grecaptcha' in window) {
        if (this.size === 'invisible') {
          this._grecaptcha.execute(widgetId)

          window.recaptchaSuccessCallback = (token) => {
            this._eventBus.emit('recaptcha-success', token)
            resolve(token)
          }

          window.recaptchaErrorCallback = (error) => {
            this._eventBus.emit('recaptcha-error', error)
            reject(error)
          }
        } else {
          const response = this._grecaptcha.getResponse(widgetId)

          if (response) {
            this._eventBus.emit('recaptcha-success', response)
            resolve(response)
          } else {
            const errorMessage = 'Failed to execute'

            this._eventBus.emit('recaptcha-error', errorMessage)
            reject(errorMessage)
          }
        }
      }
    })
  }

  init() {
    this.startObservingRecaptchaModal()
    if (this._ready) {
      // make sure caller waits until recaptcha get ready
      return this._ready
    }

    this._eventBus = new EventEmitter()
    this._elements = {
      script: document.createElement('script'),
      style: document.createElement('style'),
    }

    const { script, style } = this._elements

    script.setAttribute('async', '')
    script.setAttribute('defer', '')

    const params = []
    if (this.version === 3) {
      params.push('render=' + this.siteKey)
    }
    if (this.language) {
      params.push('hl=' + this.language)
    }

    let scriptUrl = API_URL

    if (this.mode === 'enterprise') {
      scriptUrl = scriptUrl.replace('api.js', 'enterprise.js')
      params.push('render=' + this.siteKey)
    }

    script.setAttribute('src', scriptUrl + '?' + params.join('&'))

    window.recaptchaSuccessCallback = (token) =>
      this._eventBus.emit('recaptcha-success', token)
    window.recaptchaExpiredCallback = () =>
      this._eventBus.emit('recaptcha-expired')
    window.recaptchaErrorCallback = () =>
      this._eventBus.emit('recaptcha-error', 'Failed to execute')

    this._ready = new Promise((resolve, reject) => {
      script.addEventListener('load', () => {
        if (this.version === 3 && this.hideBadge) {
          style.innerHTML = '.grecaptcha-badge { display: none }'
          document.head.appendChild(style)
        } else if (this.version === 2 && this.hideBadge) {
          // display: none DISABLES the spam checking!
          // ref: https://stackoverflow.com/questions/44543157/how-to-hide-the-google-invisible-recaptcha-badge
          style.innerHTML = '.grecaptcha-badge { visibility: hidden; }'
          document.head.appendChild(style)
        }

        this._grecaptcha = window.grecaptcha.enterprise || window.grecaptcha
        this._grecaptcha.ready(resolve)
      })

      script.addEventListener('error', () => {
        document.head.removeChild(script)
        reject(new Error('ReCaptcha error: Failed to load script'))
        this._ready = null
      })

      document.head.appendChild(script)
    })

    return this._ready
  }

  on(event, callback) {
    return this._eventBus.on(event, callback)
  }

  reset(widgetId) {
    if (this.version === 2 || typeof widgetId !== 'undefined') {
      this._grecaptcha.reset(widgetId)
    }
  }

  render(reference, { sitekey, theme }) {
    return this._grecaptcha.render(reference.$el || reference, {
      sitekey,
      theme,
    })
  }

  startObservingRecaptchaModal() {
    if (!this.observer) {
      // https://stackoverflow.com/a/72388254/5371505
      this.observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          mutation.addedNodes.forEach((node) => {
            if (
              node.tagName === 'IFRAME' &&
              node.title.match('recaptcha challenge expires in two minutes')
            ) {
              const div = node.parentNode?.parentNode?.firstChild
              if (div) {
                div.addEventListener('click', () => {
                  if (window.recaptchaErrorCallback) {
                    window.recaptchaErrorCallback(
                      new Error('Recaptcha was clicked outside'),
                    )
                  }
                })
              }
            }
          })
        })
      })
      this.observer.observe(document.body, { childList: true, subtree: true })
    }
  }

  stopObservingRecaptchaModal() {
    if (this.observer) {
      this.observer.disconnect()
    }
  }
}

export default function ({ $config: { recaptchaSiteKey } }, inject) {
  const options = {
    hideBadge: false, // Hide badge element (v3 & v2 via size=invisible)
    siteKey: recaptchaSiteKey, // Site key for requests
    version: 2, // Version
    size: 'invisible', // Size: 'compact', 'normal', 'invisible' (v2)
  }
  // eslint-disable-next-line vue/multi-word-component-names
  Vue.component('Recaptcha', () => import('~/components/common/Recaptcha.vue'))
  inject('recaptcha', new ReCaptcha(options))
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants