Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 5 additions & 34 deletions src/Auth/SendsPasswordResetEmails.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,12 @@ public function sendResetLinkEmail(Request $request)
{
$this->validateEmail($request);

// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink(
$this->credentials($request)
);
// Always return the generic "reset link sent" response regardless of the broker's
// actual result. INVALID_USER and RESET_THROTTLED would each reveal whether the
// email belongs to a registered account (the broker only throttles real users).
$this->broker()->sendResetLink($this->credentials($request));

return $response == Password::RESET_LINK_SENT
? $this->sendResetLinkResponse($request, $response)
: $this->sendResetLinkFailedResponse($request, $response);
return $this->sendResetLinkResponse($request, Password::RESET_LINK_SENT);
}

/**
Expand Down Expand Up @@ -97,31 +93,6 @@ protected function sendResetLinkResponse(Request $request, $response)
: $redirect->with('status', trans($response));
}

/**
* Get the response for a failed password reset link.
*
* @param string $response
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/
protected function sendResetLinkFailedResponse(Request $request, $response)
{
$errorRedirect = $request->input('_error_redirect');

$redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect)
? redirect($errorRedirect)
: back();

if ($request->wantsJson()) {
throw ValidationException::withMessages([
'email' => [trans($response)],
]);
}

return $redirect
->withInput($request->only('email'))
->withErrors(['email' => trans($response)], 'user.forgot_password');
}

/**
* Get the broker to be used during password reset.
*
Expand Down
83 changes: 83 additions & 0 deletions tests/Auth/CpForgotPasswordTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace Tests\Auth;

use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Auth\Passwords\PasswordReset;
use Statamic\Facades\User;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class CpForgotPasswordTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

public function tearDown(): void
{
// Prevent leaking into other tests
PasswordReset::resetFormUrl(null);
PasswordReset::resetFormRoute(null);
PasswordReset::redirectAfterReset(null);

parent::tearDown();
}

#[Test]
public function it_returns_generic_success_for_non_existent_user_to_prevent_enumeration()
{
Notification::fake();

$this
->post(cp_route('password.email'), [
'email' => 'nobody@example.com',
])
->assertSessionHasNoErrors()
->assertSessionHas('user.forgot_password.success', __(Password::RESET_LINK_SENT))
->assertSessionHas('status', __(Password::RESET_LINK_SENT));

Notification::assertNothingSent();
}

#[Test]
public function it_returns_generic_success_for_throttled_user_to_prevent_enumeration()
{
Notification::fake();

$throttled = new class
{
public function sendResetLink()
{
return Password::RESET_THROTTLED;
}
};

Password::shouldReceive('broker')->andReturn($throttled);

User::make()
->email('san@holo.com')
->password('chewy')
->save();

$this
->post(cp_route('password.email'), [
'email' => 'san@holo.com',
])
->assertSessionHasNoErrors()
->assertSessionHas('user.forgot_password.success', __(Password::RESET_LINK_SENT))
->assertSessionHas('status', __(Password::RESET_LINK_SENT));

Notification::assertNothingSent();
}

#[Test]
public function it_still_errors_on_invalid_email_format()
{
$this
->post(cp_route('password.email'), [
'email' => 'not-an-email',
])
->assertSessionHasErrors('email', null, 'user.forgot_password');
}
}
88 changes: 45 additions & 43 deletions tests/Tags/User/ForgotPasswordFormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public function it_renders_form_with_redirects_to_anchor()
}

#[Test]
public function it_wont_send_reset_link_for_non_existent_user_and_renders_errors()
public function it_returns_generic_success_for_non_existent_user_to_prevent_enumeration()
{
$this
->post('/!/auth/password/email', [
Expand All @@ -101,17 +101,32 @@ public function it_wont_send_reset_link_for_non_existent_user_and_renders_errors
preg_match_all('/<p class="success">(.+)<\/p>/U', $output, $success);
preg_match_all('/<p class="email_sent">(.+)<\/p>/U', $output, $emailSent);

$this->assertEquals([__(Password::INVALID_USER)], $errors[1]);
$this->assertEmpty($success[1]);
$this->assertEmpty($emailSent[1]);
$this->assertEmpty($errors[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $success[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $emailSent[1]);
}

#[Test]
public function it_wont_send_reset_link_for_invalid_email_and_renders_errors()
public function it_returns_generic_success_for_throttled_user_to_prevent_enumeration()
{
$throttled = new class
{
public function sendResetLink()
{
return Password::RESET_THROTTLED;
}
};

Password::shouldReceive('broker')->andReturn($throttled);

User::make()
->email('san@holo.com')
->password('chewy')
->save();

$this
->post('/!/auth/password/email', [
'email' => 'test',
'email' => 'san@holo.com',
])
->assertLocation('/');

Expand All @@ -130,24 +145,17 @@ public function it_wont_send_reset_link_for_invalid_email_and_renders_errors()
preg_match_all('/<p class="success">(.+)<\/p>/U', $output, $success);
preg_match_all('/<p class="email_sent">(.+)<\/p>/U', $output, $emailSent);

$this->assertEquals([__('validation.email', ['attribute' => 'email'])], $errors[1]);
$this->assertEmpty($success[1]);
$this->assertEmpty($emailSent[1]);
$this->assertEmpty($errors[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $success[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $emailSent[1]);
}

#[Test]
public function it_will_send_password_reset_email_and_render_success()
public function it_wont_send_reset_link_for_invalid_email_and_renders_errors()
{
$this->simulateSuccessfulPasswordResetEmail();

User::make()
->email('san@holo.com')
->password('chewy')
->save();

$this
->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'email' => 'test',
])
->assertLocation('/');

Expand All @@ -156,7 +164,6 @@ public function it_will_send_password_reset_email_and_render_success()
{{ errors }}
<p class="error">{{ value }}</p>
{{ /errors }}

<p class="success">{{ success }}</p>
<p class="email_sent">{{ email_sent }}</p>
{{ /user:forgot_password_form }}
Expand All @@ -167,13 +174,13 @@ public function it_will_send_password_reset_email_and_render_success()
preg_match_all('/<p class="success">(.+)<\/p>/U', $output, $success);
preg_match_all('/<p class="email_sent">(.+)<\/p>/U', $output, $emailSent);

$this->assertEmpty($errors[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $success[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $emailSent[1]);
$this->assertEquals([__('validation.email', ['attribute' => 'email'])], $errors[1]);
$this->assertEmpty($success[1]);
$this->assertEmpty($emailSent[1]);
}

#[Test]
public function it_will_send_password_reset_email_and_follow_custom_redirect_with_success()
public function it_will_send_password_reset_email_and_render_success()
{
$this->simulateSuccessfulPasswordResetEmail();

Expand All @@ -185,9 +192,8 @@ public function it_will_send_password_reset_email_and_follow_custom_redirect_wit
$this
->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_redirect' => '/password-reset-successful',
])
->assertLocation('/password-reset-successful');
->assertLocation('/');

$output = $this->tag(<<<'EOT'
{{ user:forgot_password_form }}
Expand All @@ -211,20 +217,28 @@ public function it_will_send_password_reset_email_and_follow_custom_redirect_wit
}

#[Test]
public function it_wont_log_user_in_and_follow_custom_error_redirect_with_errors()
public function it_will_send_password_reset_email_and_follow_custom_redirect_with_success()
{
$this->simulateSuccessfulPasswordResetEmail();

User::make()
->email('san@holo.com')
->password('chewy')
->save();

$this
->post('/!/auth/password/email', [
'email' => 'san@holo.com',
'_error_redirect' => '/password-reset-error',
'_redirect' => '/password-reset-successful',
])
->assertLocation('/password-reset-error');
->assertLocation('/password-reset-successful');

$output = $this->tag(<<<'EOT'
{{ user:forgot_password_form }}
{{ errors }}
<p class="error">{{ value }}</p>
{{ /errors }}

<p class="success">{{ success }}</p>
<p class="email_sent">{{ email_sent }}</p>
{{ /user:forgot_password_form }}
Expand All @@ -235,9 +249,9 @@ public function it_wont_log_user_in_and_follow_custom_error_redirect_with_errors
preg_match_all('/<p class="success">(.+)<\/p>/U', $output, $success);
preg_match_all('/<p class="email_sent">(.+)<\/p>/U', $output, $emailSent);

$this->assertEquals([__(Password::INVALID_USER)], $errors[1]);
$this->assertEmpty($success[1]);
$this->assertEmpty($emailSent[1]);
$this->assertEmpty($errors[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $success[1]);
$this->assertEquals([__(Password::RESET_LINK_SENT)], $emailSent[1]);
}

#[Test]
Expand All @@ -259,18 +273,6 @@ public function it_wont_follow_redirect_to_external_url()
->assertLocation('/forgot-password');
}

#[Test]
public function it_wont_follow_redirect_to_external_url_on_error()
{
$this
->from('/forgot-password')
->post('/!/auth/password/email', [
'email' => 'nonexistent@test.com',
'_error_redirect' => 'https://external-site.com/phishing',
])
->assertLocation('/forgot-password');
}

#[Test]
public function it_will_use_redirect_query_param_off_url()
{
Expand Down
Loading