-
Notifications
You must be signed in to change notification settings - Fork 319
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
Add reCAPTCHA support and update captcha provider handling #647
Changes from 2 commits
3a2fab1
7945873
f0e065d
2d06841
dfc4825
60c720d
6b0c671
c649d8c
3cbfaaf
6cf551b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,7 @@ | |
use App\Rules\StorageFile; | ||
use App\Rules\ValidHCaptcha; | ||
use App\Rules\ValidPhoneInputRule; | ||
use App\Rules\ValidReCaptcha; | ||
use App\Rules\ValidUrl; | ||
use App\Service\Forms\FormLogicPropertyResolver; | ||
use Illuminate\Foundation\Http\FormRequest; | ||
|
@@ -118,7 +119,11 @@ public function rules() | |
|
||
// Validate hCaptcha | ||
if ($this->form->use_captcha) { | ||
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()]; | ||
if ($this->form->captcha_provider === 'recaptcha') { | ||
$this->requestRules['g-recaptcha-response'] = [new ValidReCaptcha()]; | ||
} elseif ($this->form->captcha_provider === 'hcaptcha') { | ||
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()]; | ||
} | ||
Comment on lines
+122
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add default case handling for captcha validation. While the current implementation handles both reCAPTCHA and hCaptcha, it lacks handling for unexpected Consider adding a default case: if ($this->form->use_captcha) {
if ($this->form->captcha_provider === 'recaptcha') {
$this->requestRules['g-recaptcha-response'] = [new ValidReCaptcha()];
} elseif ($this->form->captcha_provider === 'hcaptcha') {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
+ } else {
+ // Default to hCaptcha if provider is invalid
+ $this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}
}
|
||
} | ||
|
||
// Validate submission_id for edit mode | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,51 @@ | ||||||||||||||||||||||||||||||||||||
<?php | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
namespace App\Rules; | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
use Closure; | ||||||||||||||||||||||||||||||||||||
use Illuminate\Contracts\Validation\ImplicitRule; | ||||||||||||||||||||||||||||||||||||
use Illuminate\Support\Facades\Http; | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
class ValidReCaptcha implements ImplicitRule | ||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||
public const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
private $error = 'Invalid CAPTCHA. Please prove you\'re not a bot.'; | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||||||||
* Determine if the validation rule passes. | ||||||||||||||||||||||||||||||||||||
* | ||||||||||||||||||||||||||||||||||||
* @param string $attribute | ||||||||||||||||||||||||||||||||||||
* @param mixed $value | ||||||||||||||||||||||||||||||||||||
* @return bool | ||||||||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||||||||
public function passes($attribute, $value) | ||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||
if (empty($value)) { | ||||||||||||||||||||||||||||||||||||
$this->error = 'Please complete the captcha.'; | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
return false; | ||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
return Http::asForm()->post(self::RECAPTCHA_VERIFY_URL, [ | ||||||||||||||||||||||||||||||||||||
'secret' => config('services.re_captcha.secret_key'), | ||||||||||||||||||||||||||||||||||||
'response' => $value, | ||||||||||||||||||||||||||||||||||||
])->json('success'); | ||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||
Comment on lines
+30
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Enhance reCAPTCHA verification security and reliability The verification request should include additional security measures and error handling:
return Http::asForm()->post(self::RECAPTCHA_VERIFY_URL, [
'secret' => config('services.re_captcha.secret_key'),
'response' => $value,
+ 'remoteip' => request()->ip(),
- ])->json('success');
+ ])
+ ->timeout(5)
+ ->throw(function($response, $e) {
+ $this->error = 'CAPTCHA verification failed. Please try again.';
+ return false;
+ })
+ ->json('success'); 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||
public function validate(string $attribute, mixed $value, Closure $fail): void | ||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||
if (!$this->passes($attribute, $value)) { | ||||||||||||||||||||||||||||||||||||
$fail($this->message()); | ||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
/** | ||||||||||||||||||||||||||||||||||||
* Get the validation error message. | ||||||||||||||||||||||||||||||||||||
* | ||||||||||||||||||||||||||||||||||||
* @return string | ||||||||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||||||||
public function message() | ||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||
return $this->error; | ||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
<?php | ||
|
||
use Illuminate\Database\Migrations\Migration; | ||
use Illuminate\Database\Schema\Blueprint; | ||
use Illuminate\Support\Facades\Schema; | ||
|
||
return new class () extends Migration { | ||
public function up() | ||
{ | ||
Schema::table('forms', function (Blueprint $table) { | ||
$table->string('captcha_provider')->default('hcaptcha')->after('use_captcha'); | ||
}); | ||
} | ||
|
||
public function down() | ||
{ | ||
Schema::table('forms', function (Blueprint $table) { | ||
$table->dropColumn('captcha_provider'); | ||
}); | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,76 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
<template> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
<div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
<div | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
ref="recaptchaContainer" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
class="g-recaptcha" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
:data-sitekey="sitekey" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
:data-theme="theme" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
</template> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
<script setup> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
const props = defineProps({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
sitekey: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
type: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
required: true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
theme: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
type: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
default: 'light' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
const emit = defineEmits(['verify', 'expired']) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
const recaptchaContainer = ref(null) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
onMounted(async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (!document.querySelector('script[src*="recaptcha/api.js"]')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
const script = document.createElement('script') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
script.src = 'https://www.google.com/recaptcha/api.js' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
script.async = true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
script.defer = true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
document.head.appendChild(script) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
await new Promise((resolve) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
script.onload = resolve | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Consider using a more secure script loading approach The current implementation directly creates and appends a script tag. Consider using CSP-compliant script loading or a dedicated script loader utility. - const script = document.createElement('script')
- script.src = 'https://www.google.com/recaptcha/api.js'
- script.async = true
- script.defer = true
- document.head.appendChild(script)
+ await useScriptLoader('https://www.google.com/recaptcha/api.js', {
+ async: true,
+ defer: true,
+ nonce: YOUR_CSP_NONCE // Add nonce for CSP compliance
+ })
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
// Wait for grecaptcha to be available | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
await new Promise((resolve) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
const checkGrecaptcha = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (window.grecaptcha?.ready) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
resolve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
setTimeout(checkGrecaptcha, 100) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
checkGrecaptcha() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add timeout to prevent infinite polling The grecaptcha availability check could potentially run indefinitely. Add a timeout mechanism. await new Promise((resolve) => {
+ const MAX_RETRIES = 50; // 5 seconds
+ let retries = 0;
const checkGrecaptcha = () => {
if (window.grecaptcha?.ready) {
resolve()
+ } else if (retries >= MAX_RETRIES) {
+ console.error('reCAPTCHA failed to load')
+ resolve()
} else {
+ retries++
setTimeout(checkGrecaptcha, 100)
}
}
checkGrecaptcha()
}) 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
window.recaptchaCallback = (token) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
emit('verify', token) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
window.recaptchaExpiredCallback = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
emit('expired') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid using global window functions for callbacks Using global window functions can lead to naming conflicts and security issues. Consider using a more encapsulated approach. - window.recaptchaCallback = (token) => {
- emit('verify', token)
- }
-
- window.recaptchaExpiredCallback = () => {
- emit('expired')
- }
+ const callbacks = {
+ verify: (token) => emit('verify', token),
+ expired: () => emit('expired')
+ }
+
+ // Use these callbacks directly in grecaptcha.render
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
window.grecaptcha.render(recaptchaContainer.value, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
sitekey: props.sitekey, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
theme: props.theme, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
callback: 'recaptchaCallback', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
'expired-callback': 'recaptchaExpiredCallback' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
console.error('Error rendering reCAPTCHA:', error) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
onBeforeUnmount(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
delete window.recaptchaCallback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
delete window.recaptchaExpiredCallback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
</script> |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -78,17 +78,31 @@ | |
<!-- Captcha --> | ||
<template v-if="form.use_captcha && isLastPage"> | ||
<div class="mb-3 px-2 mt-2 mx-auto w-max"> | ||
<vue-hcaptcha | ||
ref="hcaptcha" | ||
:sitekey="hCaptchaSiteKey" | ||
:theme="darkMode?'dark':'light'" | ||
@opened="setMinHeight(500)" | ||
@closed="setMinHeight(0)" | ||
/> | ||
<has-error | ||
:form="dataForm" | ||
field-id="h-captcha-response" | ||
/> | ||
<template v-if="form.captcha_provider === 'recaptcha'"> | ||
<RecaptchaV2 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Use Vue refs instead of direct DOM access for captcha responses Accessing DOM elements directly with Apply the following changes: In the template (line 82~): Add a <RecaptchaV2
+ ref="recaptcha"
:sitekey="recaptchaSiteKey"
:theme="darkMode ? 'dark' : 'light'"
@verify="onRecaptchaVerify"
@expired="onRecaptchaExpired"
/> In the script (lines 389~ to 395~): Update the if (this.form.captcha_provider === 'recaptcha') {
- this.dataForm['g-recaptcha-response'] = document.getElementsByName('g-recaptcha-response')[0]?.value
- window.grecaptcha?.reset()
+ this.dataForm['g-recaptcha-response'] = this.$refs.recaptcha.getResponse()
+ this.$refs.recaptcha.reset()
} else if (this.form.captcha_provider === 'hcaptcha') {
- this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value
+ this.dataForm['h-captcha-response'] = this.$refs.hcaptcha.getResponse()
this.$refs.hcaptcha.reset()
} Note: Ensure that you handle cases where the refs might not exist to prevent runtime errors. For example: if (this.form.captcha_provider === 'recaptcha' && this.$refs.recaptcha) {
// ...
} Also applies to: 389-395 |
||
:sitekey="recaptchaSiteKey" | ||
:theme="darkMode ? 'dark' : 'light'" | ||
@verify="onRecaptchaVerify" | ||
@expired="onRecaptchaExpired" | ||
/> | ||
<has-error | ||
:form="dataForm" | ||
field-id="g-recaptcha-response" | ||
/> | ||
</template> | ||
<template v-if="form.captcha_provider === 'hcaptcha'"> | ||
<vue-hcaptcha | ||
ref="hcaptcha" | ||
:sitekey="hCaptchaSiteKey" | ||
:theme="darkMode?'dark':'light'" | ||
@opened="setMinHeight(500)" | ||
@closed="setMinHeight(0)" | ||
/> | ||
<has-error | ||
:form="dataForm" | ||
field-id="h-captcha-response" | ||
/> | ||
</template> | ||
</div> | ||
</template> | ||
|
||
|
@@ -214,6 +228,9 @@ export default { | |
hCaptchaSiteKey() { | ||
return useRuntimeConfig().public.hCaptchaSiteKey | ||
}, | ||
recaptchaSiteKey() { | ||
return useRuntimeConfig().public.recaptchaSiteKey | ||
}, | ||
/** | ||
* Create field groups (or Page) using page breaks if any | ||
*/ | ||
|
@@ -369,8 +386,13 @@ export default { | |
if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return | ||
|
||
if (this.form.use_captcha && import.meta.client) { | ||
this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value | ||
this.$refs.hcaptcha.reset() | ||
if (this.form.captcha_provider === 'recaptcha') { | ||
this.dataForm['g-recaptcha-response'] = document.getElementsByName('g-recaptcha-response')[0]?.value | ||
window.grecaptcha?.reset() | ||
} else if (this.form.captcha_provider === 'hcaptcha') { | ||
this.dataForm['h-captcha-response'] = document.getElementsByName('h-captcha-response')[0].value | ||
this.$refs.hcaptcha.reset() | ||
} | ||
} | ||
|
||
if (this.form.editable_submissions && this.form.submission_id) { | ||
|
@@ -593,6 +615,12 @@ export default { | |
} catch (e) { | ||
console.error(e) | ||
} | ||
}, | ||
onRecaptchaVerify(token) { | ||
this.dataForm['g-recaptcha-response'] = token | ||
}, | ||
onRecaptchaExpired() { | ||
this.dataForm['g-recaptcha-response'] = null | ||
} | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codebase verification
Documentation needed for reCAPTCHA environment variables
The new reCAPTCHA environment variables are not documented in the environment variables configuration file (
docs/configuration/environment-variables.mdx
). Please add the following to the Configuration Environment Variables table:RE_CAPTCHA_SITE_KEY
: Site key for reCAPTCHA integrationRE_CAPTCHA_SECRET_KEY
: Secret key for reCAPTCHA integration🔗 Analysis chain
Verify documentation for new reCAPTCHA configuration
The environment variables are correctly added. Please ensure these new variables are documented in:
🏁 Scripts executed
The following scripts were executed for the analysis:
Script:
Length of output: 62
Script:
Length of output: 907
Script:
Length of output: 5373