diff --git a/.env.example b/.env.example index 75656fe6..5658fd5f 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,9 @@ GOOGLE_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +RECAPTCHA_CLIENT_ID= +RECAPTCHA_CLIENT_SECRET= + SANCTUM_STATEFUL_DOMAINS=localhost,localhost:8000,127.0.0.1,127.0.0.1:8000,::1 IS_ADMIN_VUEFILEMANAGER_BAR=true diff --git a/config/services.php b/config/services.php index 184d6c81..4770d169 100644 --- a/config/services.php +++ b/config/services.php @@ -51,4 +51,9 @@ return [ 'client_secret' => env('FACEBOOK_CLIENT_SECRET'), 'redirect' => env('APP_URL') . '/socialite/facebook/callback', ], + + 'recaptcha' => [ + 'client_id' => env('RECAPTCHA_CLIENT_ID'), + 'client_secret' => env('RECAPTCHA_CLIENT_SECRET'), + ], ]; diff --git a/package-lock.json b/package-lock.json index fcf589f5..4cf54fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9103,6 +9103,11 @@ "readable-stream": "^2.0.2" } }, + "recaptcha-v3": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/recaptcha-v3/-/recaptcha-v3-1.10.0.tgz", + "integrity": "sha512-aGTxYSk3FFNKnXeKDbLpgRDRyIHRZNBF5HyaXXAN1Aj4TSyyZvmoAn9CylvpqLV3pYpIQavwc+2rzhNFn5SsLQ==" + }, "recast": { "version": "0.11.23", "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", @@ -11613,9 +11618,9 @@ "dev": true }, "vue-i18n": { - "version": "8.26.8", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.26.8.tgz", - "integrity": "sha512-BN2OXolO15AKS95yNF8oOtARibaO6RxyKkAYNV4XpOmL7S4eVZYMIDtyvDv+XGZaiUmBJSH9mdNqzexvGMnK2A==" + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.27.0.tgz", + "integrity": "sha512-SX35iJHL5PJ4Gfh0Mo/q0shyHiI2V6Zkh51c+k8E9O1RKv5BQyYrCxRzpvPrsIOJEnLaeiovet3dsUB0e/kDzw==" }, "vue-loader": { "version": "15.9.7", @@ -11638,6 +11643,14 @@ "vue": "^2.6.7" } }, + "vue-recaptcha-v3": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/vue-recaptcha-v3/-/vue-recaptcha-v3-1.9.0.tgz", + "integrity": "sha512-WQIlhcOcETk3SYbEC88podUSq1J7UjmHpKgJhSy0Xm3DAjTIPjl19g9kn5KRdyTxNLiS/eR5C6H3Jk3c7b9baA==", + "requires": { + "recaptcha-v3": "^1.8.0" + } + }, "vue-resize-sensor": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz", diff --git a/package.json b/package.json index f301b354..9d127c57 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "vee-validate": "^3.4.14", "vue": "^2.6.14", "vue-feather-icons": "^5.1.0", - "vue-i18n": "^8.26.8", + "vue-i18n": "^8.27.0", "vue-paystack": "^2.0.4", + "vue-recaptcha-v3": "^1.9.0", "vue-router": "^3.5.3", "vuex": "^3.6.2" } diff --git a/public/assets/others/recaptcha.svg b/public/assets/others/recaptcha.svg new file mode 100644 index 00000000..270413ee --- /dev/null +++ b/public/assets/others/recaptcha.svg @@ -0,0 +1,12 @@ + + + recaptcha + + + + + + + + + \ No newline at end of file diff --git a/resources/js/helpers/ValidatorHelpers.js b/resources/js/helpers/ValidatorHelpers.js index 6494ded3..bd8f4003 100644 --- a/resources/js/helpers/ValidatorHelpers.js +++ b/resources/js/helpers/ValidatorHelpers.js @@ -26,6 +26,15 @@ const ValidatorHelpers = { Vue.prototype.$isInvalidEmail = function (email) { return email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) === null } + + Vue.prototype.$reCaptchaToken = async function (action) { + + await this.$recaptchaLoaded() + + let token = await this.$recaptcha(action) + + return token + } } } diff --git a/resources/js/main.js b/resources/js/main.js index ab0e359a..00ab7925 100644 --- a/resources/js/main.js +++ b/resources/js/main.js @@ -11,7 +11,8 @@ import SubscriptionHelpers from "./helpers/SubscriptionHelpers"; import ValidatorHelpers from "./helpers/ValidatorHelpers"; import functionHelpers from "./helpers/functionHelpers"; import AlertHelpers from "./helpers/AlertHelpers"; -import itemHelpers from "./helpers/itemHelpers" +import itemHelpers from "./helpers/itemHelpers"; +import { VueReCaptcha } from 'vue-recaptcha-v3'; Vue.use(VueRouter); Vue.use(SubscriptionHelpers); @@ -19,6 +20,11 @@ Vue.use(ValidatorHelpers); Vue.use(functionHelpers); Vue.use(AlertHelpers); Vue.use(itemHelpers); +Vue.use(VueReCaptcha, { siteKey: config.recaptcha_client_id, + loaderOptions: { + autoHideBadge: true + } +}) Vue.config.productionTip = false; diff --git a/resources/js/store/modules/app.js b/resources/js/store/modules/app.js index 26656052..3af55f6b 100644 --- a/resources/js/store/modules/app.js +++ b/resources/js/store/modules/app.js @@ -141,6 +141,11 @@ const mutations = { state.config.allowedGithubLogin = true state.config.isGithubLoginConfigured = true } + + if (service === 'recaptcha') { + state.config.allowedRecaptcha = true + state.config.isRecaptchaConfigured = true + } }, SET_STRIPE_CREDENTIALS(state, data) { state.config.stripe_public_key = data.key diff --git a/resources/js/views/Admin/AppSettings/AppSettingsTabs/Others.vue b/resources/js/views/Admin/AppSettings/AppSettingsTabs/Others.vue index 15235674..dbbae156 100644 --- a/resources/js/views/Admin/AppSettings/AppSettingsTabs/Others.vue +++ b/resources/js/views/Admin/AppSettings/AppSettingsTabs/Others.vue @@ -85,6 +85,56 @@ + +
+ reCaptcha + + + + + +
+ + {{ $t('Update Your Credentials') }} +
+ + + + + {{ $t('Configure Credentials') }} + + + + + + + + + + + + + + + + {{ $t('Store Credentials') }} + + + +
+
Facebook @@ -106,7 +156,7 @@ { + return response + }) + } + // Send request to get user token axios .post('/api/register', this.register) diff --git a/resources/js/views/Frontpage/ContactUs.vue b/resources/js/views/Frontpage/ContactUs.vue index 47bebeb8..cdc5bf6a 100644 --- a/resources/js/views/Frontpage/ContactUs.vue +++ b/resources/js/views/Frontpage/ContactUs.vue @@ -92,6 +92,7 @@ contact: { email: '', message: '', + reCaptcha: null, }, } }, @@ -106,6 +107,13 @@ // Start loading this.isLoading = true + // Get ReCaptcha token + if(config.allowedRecaptcha) { + this.register.reCaptcha = await this.$reCaptchaToken('register').then((response) => { + return response + }) + } + // Send request to get user token axios .post('/api/contact', this.contact) diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index 1f1d2c88..094331d1 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -128,6 +128,11 @@ stripe_public_key: '{{ env('STRIPE_PUBLIC_KEY') }}', stripe_payment_description: '{{ $settings->stripe_payment_description ?? '' }}', + // ReCaptcha + recaptcha_client_id: '{{ env('RECAPTCHA_CLIENT_ID') }}', + allowedRecaptcha: {{ $settings->allowed_recaptcha ?? 0 }}, + isRecaptchaConfigured: {{ env('RECAPTCHA_CLIENT_ID') ? 1 : 0 }}, + // Social logins allowedFacebookLogin: {{ $settings->allowed_facebook_login ?? 0 }}, isFacebookLoginConfigured: {{ env('FACEBOOK_CLIENT_ID') ? 1 : 0 }}, diff --git a/src/App/Users/Requests/RegisterUserRequest.php b/src/App/Users/Requests/RegisterUserRequest.php index 8ed4e547..4514304b 100644 --- a/src/App/Users/Requests/RegisterUserRequest.php +++ b/src/App/Users/Requests/RegisterUserRequest.php @@ -2,8 +2,10 @@ namespace App\Users\Requests; use App\Users\Rules\EmailProvider; +use App\Users\Rules\ReCaptchaRules; use Illuminate\Foundation\Http\FormRequest; use App\Users\Rules\PasswordValidationRules; +use Illuminate\Validation\Rules\RequiredIf; class RegisterUserRequest extends FormRequest { @@ -30,6 +32,7 @@ class RegisterUserRequest extends FormRequest 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', new EmailProvider], 'name' => 'required|string|max:255', 'password' => $this->passwordRules(), + 'reCaptcha' => [new RequiredIf(get_settings('allowed_recaptcha') == 1), 'string', app(ReCaptchaRules::class)] ]; } } diff --git a/src/App/Users/Rules/ReCaptchaRules.php b/src/App/Users/Rules/ReCaptchaRules.php new file mode 100644 index 00000000..7fc419c3 --- /dev/null +++ b/src/App/Users/Rules/ReCaptchaRules.php @@ -0,0 +1,38 @@ +post('https://www.google.com/recaptcha/api/siteverify', + [ + 'form_params' => [ + 'secret' => env('RECAPTCHA_CLIENT_SECRET', false), + 'remoteip' => request()->getClientIp(), + 'response' => $value + ] + ] + ); + $body = json_decode((string)$response->getBody()); + + return $body->success; + } + + public function message(): string + { + return 'Are you a robot?'; + } +} \ No newline at end of file diff --git a/src/Domain/Homepage/Requests/SendContactMessageRequest.php b/src/Domain/Homepage/Requests/SendContactMessageRequest.php index db2835f8..8293b0e6 100644 --- a/src/Domain/Homepage/Requests/SendContactMessageRequest.php +++ b/src/Domain/Homepage/Requests/SendContactMessageRequest.php @@ -1,7 +1,9 @@ 'required|email', 'message' => 'required|string', + 'reCaptcha' => [new RequiredIf(get_settings('allowed_recaptcha') == 1), 'string', app(ReCaptchaRules::class)] ]; } } diff --git a/src/Domain/Settings/Controllers/StoreSocialServiceCredentialsController.php b/src/Domain/Settings/Controllers/StoreSocialServiceCredentialsController.php index f541a43d..37e6a0cc 100644 --- a/src/Domain/Settings/Controllers/StoreSocialServiceCredentialsController.php +++ b/src/Domain/Settings/Controllers/StoreSocialServiceCredentialsController.php @@ -18,7 +18,7 @@ class StoreSocialServiceCredentialsController // Set on social login Setting::updateOrCreate([ - 'name' => "allowed_{$request->input('service')}_login", + 'name' => "allowed_{$request->input('service')}", ], [ 'value' => 1, ]); @@ -27,7 +27,7 @@ class StoreSocialServiceCredentialsController if (! app()->runningUnitTests()) { $credentials = [ 'facebook' => [ - 'FACEBOOK_CLIENT_ID' => $request->input('client_id'), + 'FACEBOOK_CLIENT_ID' => $request->input('client_id'), 'FACEBOOK_CLIENT_SECRET' => $request->input('client_secret'), ], 'google' => [ @@ -38,6 +38,11 @@ class StoreSocialServiceCredentialsController 'GITHUB_CLIENT_ID' => $request->input('client_id'), 'GITHUB_CLIENT_SECRET' => $request->input('client_secret'), ], + 'recaptcha' => [ + 'RECAPTCHA_CLIENT_ID' => $request->input('client_id'), + 'RECAPTCHA_CLIENT_SECRET' => $request->input('client_secret'), + ], + ]; // Store credentials into the .env file diff --git a/tests/App/Users/SignFlowTest.php b/tests/App/Users/SignFlowTest.php index 16e89eff..3203080b 100644 --- a/tests/App/Users/SignFlowTest.php +++ b/tests/App/Users/SignFlowTest.php @@ -5,6 +5,7 @@ use Storage; use Notification; use Tests\TestCase; use App\Users\Models\User; +use App\Users\Rules\ReCaptchaRules; use Domain\Settings\Models\Setting; use Illuminate\Support\Facades\Password; use App\Users\Notifications\ResetPassword; @@ -329,4 +330,33 @@ class SignFlowTest extends TestCase 'email' => $user->email, ]); } + /** + * @test + */ + public function it_create_user_from_register_form_with_reCaptcha() + { + + Setting::updateOrCreate([ + 'name' => 'allowed_recaptcha', + ], [ + 'value' => 1, + ]); + + $this->mock(ReCaptchaRules::class, function ($mock) { + $mock->shouldReceive('passes')->andReturn(true); + }); + + $this->postJson('api/register', [ + 'email' => 'john@doe.com', + 'password' => 'SecretPassword', + 'password_confirmation' => 'SecretPassword', + 'name' => 'John Doe', + 'reCaptcha' => 'fakeToken' + ])->assertStatus(201); + + $this + ->assertDatabaseHas('users', [ + 'email' => 'john@doe.com', + ]); + } }