Skip to content

Commit 14abc2f

Browse files
chore: Add PR's requested changes
1 parent 34a6926 commit 14abc2f

12 files changed

Lines changed: 262 additions & 10 deletions

File tree

app/Providers/AppServiceProvider.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
**/
1414

1515
use App\libs\Utils\TextUtils;
16+
use App\Services\TurnstileClient;
1617
use Illuminate\Support\Facades\App;
1718
use Illuminate\Support\Facades\Config;
1819
use Illuminate\Support\Facades\Event;
1920
use Illuminate\Support\Facades\Log;
2021
use Illuminate\Support\ServiceProvider;
2122
use Illuminate\Support\Facades\Validator;
2223
use models\exceptions\ValidationException;
24+
use RyanChandler\LaravelCloudflareTurnstile\Contracts\ClientInterface;
2325
use Sokil\IsoCodes\IsoCodesFactory;
2426
use Validators\CustomValidator;
2527
use App\Http\Utils\Log\LaravelMailerHandler;
@@ -142,6 +144,9 @@ public function boot()
142144
*/
143145
public function register()
144146
{
145-
//
147+
// Override vendor Client v3.0.3 whose siteverify() has inverted logic.
148+
$this->app->scoped(ClientInterface::class, function ($app) {
149+
return new TurnstileClient($app['config']->get('services.turnstile.secret'));
150+
});
146151
}
147152
}

app/Services/TurnstileClient.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php namespace App\Services;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Illuminate\Support\Facades\Http;
16+
use RyanChandler\LaravelCloudflareTurnstile\Contracts\ClientInterface;
17+
use RyanChandler\LaravelCloudflareTurnstile\Responses\SiteverifyResponse;
18+
19+
/**
20+
* Replaces the vendor Client (v3.0.3) whose siteverify() has inverted
21+
* success/failure logic: it returns success() on HTTP non-2xx and failure()
22+
* on HTTP 2xx, causing a TypeError when Cloudflare returns {success:true}.
23+
*/
24+
final class TurnstileClient implements ClientInterface
25+
{
26+
public function __construct(private string $secret) {}
27+
28+
public function siteverify(string $token): SiteverifyResponse
29+
{
30+
$response = Http::retry(3, 100)
31+
->asForm()
32+
->acceptJson()
33+
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
34+
'secret' => $this->secret,
35+
'response' => $token,
36+
]);
37+
38+
if (! $response->ok()) {
39+
return SiteverifyResponse::failure(['http-error']);
40+
}
41+
42+
if ($response->json('success') === true) {
43+
return SiteverifyResponse::success();
44+
}
45+
46+
return SiteverifyResponse::failure($response->json('error-codes') ?? []);
47+
}
48+
49+
public function dummy(): string
50+
{
51+
return self::RESPONSE_DUMMY_TOKEN;
52+
}
53+
}

resources/js/email_verification/email_verification.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ const EmailVerificationPage = ({
127127
siteKey={captchaPublicKey}
128128
options={{ responseFieldName: "cf-turnstile-response" }}
129129
onSuccess={onChangeCaptchaProvider}
130+
onExpire={() => { captcha.current?.reset(); }}
131+
onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')}
130132
/>
131133
{captchaConfirmation && (
132134
<div className={styles.error_label}>

resources/js/forgot_password/forgot_password.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ const ForgotPasswordPage = ({
143143
siteKey={captchaPublicKey}
144144
options={{ responseFieldName: "cf-turnstile-response" }}
145145
onSuccess={onChangeCaptchaProvider}
146+
onExpire={() => { captcha.current?.reset(); }}
147+
onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')}
146148
/>
147149
{captchaConfirmation && (
148150
<div className={styles.error_label}>

resources/js/login/login.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ const PasswordInputForm = ({
8585
shouldShowCaptcha,
8686
captchaPublicKey,
8787
onChangeCaptchaProvider,
88+
onExpireCaptchaProvider,
89+
onErrorCaptchaProvider,
8890
handleEmitOtpAction,
8991
forgotPasswordAction,
9092
loginAttempts,
@@ -183,12 +185,15 @@ const PasswordInputForm = ({
183185
<input type="hidden" value={userNameValue} id="username" name="username"/>
184186
<input type="hidden" value={csrfToken} id="_token" name="_token"/>
185187
<input type="hidden" value="password" id="flow" name="flow"/>
188+
<input type="hidden" value={loginAttempts} id="login_attempts" name="login_attempts"/>
186189
{shouldShowCaptcha() && captchaPublicKey &&
187190
<Turnstile
188191
className={styles.turnstile}
189192
siteKey={captchaPublicKey}
190193
options={{ responseFieldName: "cf-turnstile-response" }}
191194
onSuccess={onChangeCaptchaProvider}
195+
onExpire={onExpireCaptchaProvider}
196+
onError={onErrorCaptchaProvider}
192197
/>
193198
}
194199
<ExistingAccountActions
@@ -214,6 +219,8 @@ const OTPInputForm = ({
214219
shouldShowCaptcha,
215220
captchaPublicKey,
216221
onChangeCaptchaProvider,
222+
onExpireCaptchaProvider,
223+
onErrorCaptchaProvider,
217224
onReset,
218225
loginAttempts
219226
}) => {
@@ -264,12 +271,15 @@ const OTPInputForm = ({
264271
<input type="hidden" value="otp" id="flow" name="flow"/>
265272
<input type="hidden" value={otpCode} id="password" name="password"/>
266273
<input type="hidden" value="email" id="connection" name="connection"/>
274+
<input type="hidden" value={loginAttempts} id="login_attempts" name="login_attempts"/>
267275
{shouldShowCaptcha() && captchaPublicKey &&
268276
<Turnstile
269277
className={styles.turnstile}
270278
siteKey={captchaPublicKey}
271279
options={{ responseFieldName: "cf-turnstile-response" }}
272280
onSuccess={onChangeCaptchaProvider}
281+
onExpire={onExpireCaptchaProvider}
282+
onError={onErrorCaptchaProvider}
273283
/>
274284
}
275285
</form>
@@ -474,6 +484,8 @@ class LoginPage extends React.Component {
474484
this.handleDelete = this.handleDelete.bind(this);
475485
this.onAuthenticate = this.onAuthenticate.bind(this);
476486
this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this);
487+
this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this);
488+
this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this);
477489
this.onUserPasswordChange = this.onUserPasswordChange.bind(this);
478490
this.onOTPCodeChange = this.onOTPCodeChange.bind(this);
479491
this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this);
@@ -562,6 +574,14 @@ class LoginPage extends React.Component {
562574
this.setState({ ...this.state, captcha_value: value });
563575
}
564576

577+
onExpireCaptchaProvider() {
578+
this.setState({ ...this.state, captcha_value: '' });
579+
}
580+
581+
onErrorCaptchaProvider() {
582+
this.setState({ ...this.state, captcha_value: '' });
583+
}
584+
565585
onHandleUserNameChange(ev) {
566586
let { value, id } = ev.target;
567587
this.setState({ ...this.state, user_name: value });
@@ -833,6 +853,8 @@ class LoginPage extends React.Component {
833853
shouldShowCaptcha={this.shouldShowCaptcha}
834854
captchaPublicKey={this.props.captchaPublicKey}
835855
onChangeCaptchaProvider={this.onChangeCaptchaProvider}
856+
onExpireCaptchaProvider={this.onExpireCaptchaProvider}
857+
onErrorCaptchaProvider={this.onErrorCaptchaProvider}
836858
handleEmitOtpAction={this.handleEmitOtpAction}
837859
forgotPasswordAction={this.props.forgotPasswordAction}
838860
loginAttempts={this.props?.loginAttempts}
@@ -870,6 +892,8 @@ class LoginPage extends React.Component {
870892
shouldShowCaptcha={this.shouldShowCaptcha}
871893
captchaPublicKey={this.props.captchaPublicKey}
872894
onChangeCaptchaProvider={this.onChangeCaptchaProvider}
895+
onExpireCaptchaProvider={this.onExpireCaptchaProvider}
896+
onErrorCaptchaProvider={this.onErrorCaptchaProvider}
873897
onReset={this.handleDelete}
874898
loginAttempts={this.props?.loginAttempts}
875899
/>

resources/js/reset_password/reset_password.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ const ResetPasswordPage = ({
178178
siteKey={captchaPublicKey}
179179
options={{ responseFieldName: "cf-turnstile-response" }}
180180
onSuccess={onChangeCaptchaProvider}
181+
onExpire={() => { captcha.current?.reset(); }}
182+
onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')}
181183
/>
182184
{captchaConfirmation && (
183185
<div className={styles.error_label}>

resources/js/set_password/set_password.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ const SetPasswordPage = ({
287287
siteKey={captchaPublicKey}
288288
options={{ responseFieldName: "cf-turnstile-response" }}
289289
onSuccess={onChangeCaptchaProvider}
290+
onExpire={() => { captcha.current?.reset(); }}
291+
onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')}
290292
/>
291293
{captchaConfirmation && (
292294
<div className={styles.error_label}>

resources/js/signup/signup.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ const SignUpPage = ({
281281
siteKey={captchaPublicKey}
282282
options={{ responseFieldName: "cf-turnstile-response" }}
283283
onSuccess={onChangeCaptchaProvider}
284+
onExpire={() => { captcha.current?.reset(); }}
285+
onError={() => setCaptchaConfirmation('The security check encountered an error. Please refresh the page and try again.')}
284286
/>
285287
{captchaConfirmation && (
286288
<div className={styles.error_label}>

resources/views/auth/email_verification_success.blade.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
<title>Welcome to {{ Config::get("app.app_name") }} - Email Verification Complete !!!</title>
44
@append
55
@section('scripts')
6-
{!! script_to('assets/js/auth/email-verification-complete.js') !!}
76
<script type="application/javascript">
87
</script>
98
@append
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php namespace Tests;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Illuminate\Support\Facades\Session;
16+
17+
/**
18+
* Class TurnstileProtectedControllersTest
19+
*
20+
* Smoke tests verifying that cf-turnstile-response is always required on the
21+
* five auth endpoints that gate every submission behind Turnstile (unlike
22+
* UserController::postLogin, which only activates the rule above a threshold).
23+
*/
24+
final class TurnstileProtectedControllersTest extends BrowserKitTestCase
25+
{
26+
protected function prepareForTests(): void
27+
{
28+
parent::prepareForTests();
29+
Session::start();
30+
}
31+
32+
private function sessionHasValidationError(string $field): bool
33+
{
34+
$errors = $this->app['session']->driver()->get('errors');
35+
return $errors !== null && $errors->has($field);
36+
}
37+
38+
private function postWithSession(string $url, array $data = []): void
39+
{
40+
$this->call('GET', $url);
41+
$this->call('POST', $url, array_merge(['_token' => Session::token()], $data));
42+
}
43+
44+
// -------------------------------------------------------------------------
45+
// RegisterController
46+
// -------------------------------------------------------------------------
47+
48+
public function testRegisterRequiresTurnstileToken(): void
49+
{
50+
$this->postWithSession('/auth/register', [
51+
'first_name' => 'Test',
52+
'last_name' => 'User',
53+
'email' => 'turnstile-test@example.com',
54+
'country_iso_code'=> 'US',
55+
'password' => 'Abcd1234!',
56+
'password_confirmation' => 'Abcd1234!',
57+
// cf-turnstile-response intentionally omitted
58+
]);
59+
60+
$this->assertTrue(
61+
$this->sessionHasValidationError('cf-turnstile-response'),
62+
'RegisterController must require cf-turnstile-response'
63+
);
64+
}
65+
66+
// -------------------------------------------------------------------------
67+
// ForgotPasswordController
68+
// -------------------------------------------------------------------------
69+
70+
public function testForgotPasswordRequiresTurnstileToken(): void
71+
{
72+
$this->postWithSession('/auth/password/email', [
73+
'email' => 'anyone@example.com',
74+
// cf-turnstile-response intentionally omitted
75+
]);
76+
77+
$this->assertTrue(
78+
$this->sessionHasValidationError('cf-turnstile-response'),
79+
'ForgotPasswordController must require cf-turnstile-response'
80+
);
81+
}
82+
83+
// -------------------------------------------------------------------------
84+
// ResetPasswordController
85+
// -------------------------------------------------------------------------
86+
87+
public function testResetPasswordRequiresTurnstileToken(): void
88+
{
89+
$this->postWithSession('/auth/password/reset', [
90+
'token' => 'any-reset-token',
91+
'password' => 'Abcd1234!',
92+
'password_confirmation' => 'Abcd1234!',
93+
// cf-turnstile-response intentionally omitted
94+
]);
95+
96+
$this->assertTrue(
97+
$this->sessionHasValidationError('cf-turnstile-response'),
98+
'ResetPasswordController must require cf-turnstile-response'
99+
);
100+
}
101+
102+
// -------------------------------------------------------------------------
103+
// PasswordSetController
104+
// -------------------------------------------------------------------------
105+
106+
public function testPasswordSetRequiresTurnstileToken(): void
107+
{
108+
$this->postWithSession('/auth/password/set', [
109+
'token' => 'any-set-token',
110+
'first_name' => 'Test',
111+
'last_name' => 'User',
112+
'country_iso_code' => 'US',
113+
'password' => 'Abcd1234!',
114+
'password_confirmation' => 'Abcd1234!',
115+
// cf-turnstile-response intentionally omitted
116+
]);
117+
118+
$this->assertTrue(
119+
$this->sessionHasValidationError('cf-turnstile-response'),
120+
'PasswordSetController must require cf-turnstile-response'
121+
);
122+
}
123+
124+
// -------------------------------------------------------------------------
125+
// EmailVerificationController
126+
// -------------------------------------------------------------------------
127+
128+
public function testEmailVerificationResendRequiresTurnstileToken(): void
129+
{
130+
$this->postWithSession('/auth/verification', [
131+
'email' => 'anyone@example.com',
132+
// cf-turnstile-response intentionally omitted
133+
]);
134+
135+
$this->assertTrue(
136+
$this->sessionHasValidationError('cf-turnstile-response'),
137+
'EmailVerificationController must require cf-turnstile-response'
138+
);
139+
}
140+
}

0 commit comments

Comments
 (0)