-
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 5 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; | ||
|
@@ -116,9 +117,13 @@ public function rules() | |
$this->requestRules[$propertyId] = $rules; | ||
} | ||
|
||
// Validate hCaptcha | ||
// Validate Captcha | ||
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,179 @@ | ||
<template> | ||
<div> | ||
<div v-if="showCaptcha"> | ||
<RecaptchaV2 | ||
v-if="provider === 'recaptcha'" | ||
:key="`recaptcha-${componentKey}`" | ||
ref="captchaRef" | ||
:sitekey="recaptchaSiteKey" | ||
:theme="darkMode ? 'dark' : 'light'" | ||
:language="language" | ||
@verify="onCaptchaVerify" | ||
@expired="onCaptchaExpired" | ||
@opened="onCaptchaOpen" | ||
@closed="onCaptchaClose" | ||
/> | ||
<HCaptchaV2 | ||
v-else | ||
:key="`hcaptcha-${componentKey}`" | ||
ref="captchaRef" | ||
:sitekey="hCaptchaSiteKey" | ||
:theme="darkMode ? 'dark' : 'light'" | ||
:language="language" | ||
@verify="onCaptchaVerify" | ||
@expired="onCaptchaExpired" | ||
@opened="onCaptchaOpen" | ||
@closed="onCaptchaClose" | ||
/> | ||
</div> | ||
<has-error | ||
:form="form" | ||
:field-id="formFieldName" | ||
/> | ||
</div> | ||
</template> | ||
|
||
<script setup> | ||
import HCaptchaV2 from './HCaptchaV2.vue' | ||
import RecaptchaV2 from './RecaptchaV2.vue' | ||
|
||
const props = defineProps({ | ||
provider: { | ||
type: String, | ||
required: true, | ||
validator: (value) => ['recaptcha', 'hcaptcha'].includes(value) | ||
}, | ||
form: { | ||
type: Object, | ||
required: true | ||
}, | ||
language: { | ||
type: String, | ||
required: true | ||
}, | ||
darkMode: { | ||
type: Boolean, | ||
default: false | ||
} | ||
}) | ||
|
||
const config = useRuntimeConfig() | ||
const recaptchaSiteKey = config.public.recaptchaSiteKey | ||
const hCaptchaSiteKey = config.public.hCaptchaSiteKey | ||
|
||
const captchaRef = ref(null) | ||
const isIframe = ref(false) | ||
const showCaptcha = ref(true) | ||
const componentKey = ref(0) | ||
|
||
const formFieldName = computed(() => props.provider === 'recaptcha' ? 'g-recaptcha-response' : 'h-captcha-response') | ||
|
||
// Watch for provider changes to reset the form field | ||
watch(() => props.provider, async (newProvider, oldProvider) => { | ||
if (newProvider !== oldProvider) { | ||
// Clear old provider's value | ||
if (oldProvider === 'recaptcha') { | ||
props.form['g-recaptcha-response'] = null | ||
} else if (oldProvider === 'hcaptcha') { | ||
props.form['h-captcha-response'] = null | ||
} | ||
|
||
// Force remount by toggling visibility and incrementing key | ||
showCaptcha.value = false | ||
|
||
// Wait longer to ensure complete cleanup | ||
await new Promise(resolve => setTimeout(resolve, 1000)) | ||
|
||
componentKey.value++ | ||
await nextTick() | ||
|
||
// Wait again before showing new captcha | ||
await new Promise(resolve => setTimeout(resolve, 1000)) | ||
|
||
showCaptcha.value = true | ||
} | ||
}) | ||
|
||
onMounted(() => { | ||
isIframe.value = window.self !== window.top | ||
}) | ||
|
||
// Add a ref to track if captcha was completed | ||
const wasCaptchaCompleted = ref(false) | ||
|
||
// Handle captcha verification | ||
const onCaptchaVerify = (token) => { | ||
wasCaptchaCompleted.value = true | ||
props.form[formFieldName.value] = token | ||
// Also set the DOM element value for compatibility with existing code | ||
if (import.meta.client) { | ||
const element = document.getElementsByName(formFieldName.value)[0] | ||
if (element) element.value = token | ||
} | ||
} | ||
|
||
// Handle captcha expiration | ||
const onCaptchaExpired = () => { | ||
wasCaptchaCompleted.value = false | ||
props.form[formFieldName.value] = null | ||
// Also clear the DOM element value for compatibility with existing code | ||
if (import.meta.client) { | ||
const element = document.getElementsByName(formFieldName.value)[0] | ||
if (element) element.value = '' | ||
} | ||
} | ||
|
||
// Handle iframe resizing | ||
const resizeIframe = (height) => { | ||
if (!isIframe.value) return | ||
|
||
try { | ||
window.parentIFrame?.size(height) | ||
} catch (e) { | ||
// Silently handle error | ||
} | ||
} | ||
|
||
// Handle captcha open/close for iframe resizing | ||
const onCaptchaOpen = () => { | ||
resizeIframe(500) | ||
// Ensure the captcha is visible by scrolling to it | ||
if (import.meta.client) { | ||
nextTick(() => { | ||
const captchaElement = captchaRef.value?.$el | ||
if (captchaElement) { | ||
captchaElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
const onCaptchaClose = () => { | ||
resizeIframe(0) | ||
} | ||
|
||
// Method to reset captcha - can be called from parent | ||
defineExpose({ | ||
reset: () => { | ||
// Only do a full reset if the captcha was previously completed | ||
if (captchaRef.value) { | ||
if (wasCaptchaCompleted.value) { | ||
wasCaptchaCompleted.value = false | ||
captchaRef.value.reset() | ||
} | ||
} | ||
} | ||
}) | ||
</script> | ||
|
||
<style> | ||
.fade-enter-active, | ||
.fade-leave-active { | ||
transition: opacity 0.2s ease; | ||
} | ||
|
||
.fade-enter-from, | ||
.fade-leave-to { | ||
opacity: 0; | ||
} | ||
</style> |
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