Skip to content

Commit 61e1e4b

Browse files
authored
Merge pull request #104 from kbond/feature/throttle-retry-time
[feature] add additional detail to TooManyPasswordRequestsException
2 parents a2cc78c + 4966e58 commit 61e1e4b

File tree

5 files changed

+94
-38
lines changed

5 files changed

+94
-38
lines changed

src/Exception/TooManyPasswordRequestsException.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@
1414
*/
1515
final class TooManyPasswordRequestsException extends \Exception implements ResetPasswordExceptionInterface
1616
{
17+
private $availableAt;
18+
19+
public function __construct(\DateTimeInterface $availableAt, string $message = '', int $code = 0, \Throwable $previous = null)
20+
{
21+
parent::__construct($message, $code, $previous);
22+
23+
$this->availableAt = $availableAt;
24+
}
25+
26+
public function getAvailableAt(): \DateTimeInterface
27+
{
28+
return $this->availableAt;
29+
}
30+
31+
public function getRetryAfter(): int
32+
{
33+
return $this->getAvailableAt()->getTimestamp() - (new \DateTime('now'))->getTimestamp();
34+
}
35+
1736
public function getReason(): string
1837
{
1938
return 'You have already requested a reset password email. Please check your email or try again soon.';

src/ResetPasswordHelper.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ public function generateResetToken(object $user): ResetPasswordToken
6464
{
6565
$this->resetPasswordCleaner->handleGarbageCollection();
6666

67-
if ($this->hasUserHitThrottling($user)) {
68-
throw new TooManyPasswordRequestsException();
67+
if ($availableAt = $this->hasUserHitThrottling($user)) {
68+
throw new TooManyPasswordRequestsException($availableAt);
6969
}
7070

7171
$expiresAt = new \DateTimeImmutable(\sprintf('+%d seconds', $this->resetRequestLifetime));
@@ -158,18 +158,21 @@ private function findResetPasswordRequest(string $token): ?ResetPasswordRequestI
158158
return $this->repository->findResetPasswordRequest($selector);
159159
}
160160

161-
private function hasUserHitThrottling(object $user): bool
161+
private function hasUserHitThrottling(object $user): ?\DateTimeInterface
162162
{
163+
/** @var \DateTime|\DateTimeImmutable|null $lastRequestDate */
163164
$lastRequestDate = $this->repository->getMostRecentNonExpiredRequestDate($user);
164165

165166
if (null === $lastRequestDate) {
166-
return false;
167+
return null;
167168
}
168169

169-
if (($lastRequestDate->getTimestamp() + $this->requestThrottleTime) > \time()) {
170-
return true;
170+
$availableAt = (clone $lastRequestDate)->add(new \DateInterval("PT{$this->requestThrottleTime}S"));
171+
172+
if ($availableAt > new \DateTime('now')) {
173+
return $availableAt;
171174
}
172175

173-
return false;
176+
return null;
174177
}
175178
}

tests/UnitTests/Exception/ResetPasswordExceptionTest.php

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,38 +25,36 @@ class ResetPasswordExceptionTest extends TestCase
2525
public function exceptionDataProvider(): \Generator
2626
{
2727
yield [
28-
ExpiredResetPasswordTokenException::class,
28+
new ExpiredResetPasswordTokenException(),
2929
'The link in your email is expired. Please try to reset your password again.',
3030
];
3131
yield [
32-
InvalidResetPasswordTokenException::class,
32+
new InvalidResetPasswordTokenException(),
3333
'The reset password link is invalid. Please try to reset your password again.',
3434
];
3535
yield [
36-
TooManyPasswordRequestsException::class,
36+
new TooManyPasswordRequestsException(new \DateTime('+1 hour')),
3737
'You have already requested a reset password email. Please check your email or try again soon.',
3838
];
3939
yield [
40-
FakeRepositoryException::class,
40+
new FakeRepositoryException(),
4141
'Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository` service.',
4242
];
4343
}
4444

4545
/**
4646
* @dataProvider exceptionDataProvider
4747
*/
48-
public function testIsReason(string $exception, string $message): void
48+
public function testIsReason(ResetPasswordExceptionInterface $exception, string $message): void
4949
{
50-
$result = new $exception();
51-
self::assertSame($message, $result->getReason());
50+
self::assertSame($message, $exception->getReason());
5251
}
5352

5453
/**
5554
* @dataProvider exceptionDataProvider
5655
*/
57-
public function testImplementsResetPasswordExceptionInterface(string $exception): void
56+
public function testImplementsResetPasswordExceptionInterface(ResetPasswordExceptionInterface $exception): void
5857
{
59-
$interfaces = \class_implements($exception);
60-
self::assertArrayHasKey(ResetPasswordExceptionInterface::class, $interfaces);
58+
self::assertInstanceOf(ResetPasswordExceptionInterface::class, $exception);
6159
}
6260
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the SymfonyCasts ResetPasswordBundle package.
5+
* Copyright (c) SymfonyCasts <https://symfonycasts.com/>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace SymfonyCasts\Bundle\ResetPassword\Tests\UnitTests\Exception;
11+
12+
use PHPUnit\Framework\TestCase;
13+
use SymfonyCasts\Bundle\ResetPassword\Exception\TooManyPasswordRequestsException;
14+
15+
/**
16+
* @author Kevin Bond <kevinbond@gmail.com>
17+
*/
18+
class TooManyPasswordRequestsExceptionTest extends TestCase
19+
{
20+
public function testCanGetRetryAfter(): void
21+
{
22+
$exception = new TooManyPasswordRequestsException(new \DateTime('+1 hour'));
23+
24+
// account for time changes during test
25+
self::assertGreaterThanOrEqual(3599, $exception->getRetryAfter());
26+
self::assertLessThanOrEqual(3600, $exception->getRetryAfter());
27+
}
28+
}

tests/UnitTests/ResetPasswordHelperTest.php

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,18 @@ public function testHasUserThrottlingReturnsFalseWithNoLastRequestDate(): void
9494
/**
9595
* @covers \SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper::hasUserHitThrottling
9696
*/
97-
public function testHasUserThrottlingReturnsFalseIfNotBeforeThrottleTime(): void
97+
public function testHasUserThrottlingReturnsNullIfNotBeforeThrottleTime(): void
9898
{
9999
$this->mockRepo
100100
->expects($this->once())
101101
->method('getUserIdentifier')
102102
->willReturn('1234')
103103
;
104104

105-
$mockLastRequestTime = $this->createMock(\DateTimeImmutable::class);
106-
$mockLastRequestTime
107-
->expects($this->once())
108-
->method('getTimestamp')
109-
->willReturn(1234)
110-
;
111-
112105
$this->mockRepo
113106
->expects($this->once())
114107
->method('getMostRecentNonExpiredRequestDate')
115-
->willReturn($mockLastRequestTime)
108+
->willReturn(new \DateTime('-3 hours'))
116109
;
117110

118111
$this->mockRepo
@@ -121,29 +114,44 @@ public function testHasUserThrottlingReturnsFalseIfNotBeforeThrottleTime(): void
121114
->willReturn(new ResetPasswordTestFixtureRequest())
122115
;
123116

124-
$helper = $this->getPasswordResetHelper();
117+
$helper = new ResetPasswordHelper(
118+
$this->mockTokenGenerator,
119+
$this->mockCleaner,
120+
$this->mockRepo,
121+
99999999,
122+
7200 // 2 hours
123+
);
124+
125125
$helper->generateResetToken(new \stdClass());
126126
}
127127

128128
public function testExceptionThrownIfRequestBeforeThrottleLimit(): void
129129
{
130-
$mockLastRequestTime = $this->createMock(\DateTimeImmutable::class);
131-
$mockLastRequestTime
132-
->expects($this->once())
133-
->method('getTimestamp')
134-
->willReturn(9999999999)
135-
;
136-
137130
$this->mockRepo
138131
->expects($this->once())
139132
->method('getMostRecentNonExpiredRequestDate')
140-
->willReturn($mockLastRequestTime)
133+
->willReturn(new \DateTime('-1 hour'))
141134
;
142135

143-
$this->expectException(TooManyPasswordRequestsException::class);
136+
$helper = new ResetPasswordHelper(
137+
$this->mockTokenGenerator,
138+
$this->mockCleaner,
139+
$this->mockRepo,
140+
99999999,
141+
7200 // 2 hours
142+
);
144143

145-
$helper = $this->getPasswordResetHelper();
146-
$helper->generateResetToken(new \stdClass());
144+
try {
145+
$helper->generateResetToken(new \stdClass());
146+
} catch (TooManyPasswordRequestsException $exception) {
147+
// account for time changes during test
148+
self::assertGreaterThanOrEqual(3599, $exception->getRetryAfter());
149+
self::assertLessThanOrEqual(3600, $exception->getRetryAfter());
150+
151+
return;
152+
}
153+
154+
$this->fail('Exception was not thrown.');
147155
}
148156

149157
public function testRemoveResetRequestThrowsExceptionWithEmptyToken(): void

0 commit comments

Comments
 (0)