Skip to content

Commit 23041e9

Browse files
committed
Merge branch 'password_reset_security_fix' of https://github.com/chmv/framework into chmv-password_reset_security_fix
2 parents 998179d + c4c5f4a commit 23041e9

File tree

6 files changed

+116
-6
lines changed

6 files changed

+116
-6
lines changed

src/Illuminate/Auth/Passwords/DatabaseTokenRepository.php

+36-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ class DatabaseTokenRepository implements TokenRepositoryInterface
4545
*/
4646
protected $expires;
4747

48+
/**
49+
* Minimum number of seconds before re-redefining the token.
50+
*
51+
* @var int
52+
*/
53+
protected $timeout;
54+
4855
/**
4956
* Create a new token repository instance.
5057
*
@@ -53,15 +60,17 @@ class DatabaseTokenRepository implements TokenRepositoryInterface
5360
* @param string $table
5461
* @param string $hashKey
5562
* @param int $expires
63+
* @param int $timeout
5664
* @return void
5765
*/
5866
public function __construct(ConnectionInterface $connection, HasherContract $hasher,
59-
$table, $hashKey, $expires = 60)
67+
$table, $hashKey, $expires = 60, $timeout = 60)
6068
{
6169
$this->table = $table;
6270
$this->hasher = $hasher;
6371
$this->hashKey = $hashKey;
6472
$this->expires = $expires * 60;
73+
$this->timeout = $timeout;
6574
$this->connection = $connection;
6675
}
6776

@@ -139,6 +148,32 @@ protected function tokenExpired($createdAt)
139148
return Carbon::parse($createdAt)->addSeconds($this->expires)->isPast();
140149
}
141150

151+
/**
152+
* Determine if a token record exists and was recently created.
153+
*
154+
* @param \Illuminate\Contracts\Auth\CanResetPassword $user
155+
* @return bool
156+
*/
157+
public function recentlyCreated(CanResetPasswordContract $user)
158+
{
159+
$record = (array) $this->getTable()->where(
160+
'email', $user->getEmailForPasswordReset()
161+
)->first();
162+
163+
return $record && $this->tokenRecentlyCreated($record['created_at']);
164+
}
165+
166+
/**
167+
* Determine if the token was recently created.
168+
*
169+
* @param string $createdAt
170+
* @return bool
171+
*/
172+
protected function tokenRecentlyCreated($createdAt)
173+
{
174+
return Carbon::parse($createdAt)->addSeconds($this->timeout)->isFuture();
175+
}
176+
142177
/**
143178
* Delete a token record by user.
144179
*

src/Illuminate/Auth/Passwords/PasswordBroker.php

+10
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ public function sendResetLink(array $credentials)
5555
return static::INVALID_USER;
5656
}
5757

58+
// Before 7.x we have to check the existence of a new method.
59+
// In 7.x, this code must be removed.
60+
if (method_exists($this->tokens, 'recentlyCreated')) {
61+
// An attacker can make a lot of password reset requests,
62+
// which will lead to spam in user's mailbox.
63+
if ($this->tokens->recentlyCreated($user)) {
64+
return static::RESEND_TIMEOUT;
65+
}
66+
}
67+
5868
// Once we have the reset token, we are ready to send the message out to this
5969
// user with a link to reset their password. We will then redirect back to
6070
// the current URI having nothing set in the session to indicate errors.

src/Illuminate/Auth/Passwords/PasswordBrokerManager.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ protected function createTokenRepository(array $config)
9595
$this->app['hash'],
9696
$config['table'],
9797
$key,
98-
$config['expire']
98+
$config['expire'],
99+
// Before 7.x this element in the configuration may not exist.
100+
// In 7.x, this check must be removed.
101+
$config['timeout'] ?? 0
99102
);
100103
}
101104

src/Illuminate/Contracts/Auth/PasswordBroker.php

+7
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ interface PasswordBroker
3434
*/
3535
const INVALID_TOKEN = 'passwords.token';
3636

37+
/**
38+
* Constant representing the wait before password reset link resending.
39+
*
40+
* @var string
41+
*/
42+
const RESEND_TIMEOUT = 'passwords.timeout';
43+
3744
/**
3845
* Send a password reset link to a user.
3946
*

tests/Auth/AuthDatabaseTokenRepositoryTest.php

+38
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,44 @@ public function testExistReturnsFalseIfInvalidToken()
9898
$this->assertFalse($repo->exists($user, 'wrong-token'));
9999
}
100100

101+
public function testRecentlyCreatedReturnsFalseIfNoRowFoundForUser()
102+
{
103+
$repo = $this->getRepo();
104+
$repo->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(stdClass::class));
105+
$query->shouldReceive('where')->once()->with('email', 'email')->andReturn($query);
106+
$query->shouldReceive('first')->once()->andReturn(null);
107+
$user = m::mock(CanResetPassword::class);
108+
$user->shouldReceive('getEmailForPasswordReset')->once()->andReturn('email');
109+
110+
$this->assertFalse($repo->recentlyCreated($user));
111+
}
112+
113+
public function testRecentlyCreatedReturnsTrueIfRecordIsRecentlyCreated()
114+
{
115+
$repo = $this->getRepo();
116+
$repo->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(stdClass::class));
117+
$query->shouldReceive('where')->once()->with('email', 'email')->andReturn($query);
118+
$date = Carbon::now()->subSeconds(59)->toDateTimeString();
119+
$query->shouldReceive('first')->once()->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']);
120+
$user = m::mock(CanResetPassword::class);
121+
$user->shouldReceive('getEmailForPasswordReset')->once()->andReturn('email');
122+
123+
$this->assertTrue($repo->recentlyCreated($user));
124+
}
125+
126+
public function testRecentlyCreatedReturnsFalseIfValidRecordExists()
127+
{
128+
$repo = $this->getRepo();
129+
$repo->getConnection()->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(stdClass::class));
130+
$query->shouldReceive('where')->once()->with('email', 'email')->andReturn($query);
131+
$date = Carbon::now()->subSeconds(61)->toDateTimeString();
132+
$query->shouldReceive('first')->once()->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']);
133+
$user = m::mock(CanResetPassword::class);
134+
$user->shouldReceive('getEmailForPasswordReset')->once()->andReturn('email');
135+
136+
$this->assertFalse($repo->recentlyCreated($user));
137+
}
138+
101139
public function testDeleteMethodDeletesByToken()
102140
{
103141
$repo = $this->getRepo();

tests/Auth/AuthPasswordBrokerTest.php

+21-4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ public function testIfUserIsNotFoundErrorRedirectIsReturned()
2929
$this->assertEquals(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['credentials']));
3030
}
3131

32+
public function testIfTokenIsRecentlyCreated()
33+
{
34+
$mocks = $this->getMocks();
35+
$mocks['tokens'] = m::mock(TestTokenRepositoryInterface::class);
36+
$broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock();
37+
$mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class));
38+
$mocks['tokens']->shouldReceive('recentlyCreated')->once()->with($user)->andReturn(true);
39+
$user->shouldReceive('sendPasswordResetNotification')->with('token');
40+
41+
$this->assertEquals(PasswordBrokerContract::RESEND_TIMEOUT, $broker->sendResetLink(['foo']));
42+
}
43+
3244
public function testGetUserThrowsExceptionIfUserDoesntImplementCanResetPassword()
3345
{
3446
$this->expectException(UnexpectedValueException::class);
@@ -54,12 +66,9 @@ public function testBrokerCreatesTokenAndRedirectsWithoutError()
5466
$broker = $this->getMockBuilder(PasswordBroker::class)->setMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock();
5567
$mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class));
5668
$mocks['tokens']->shouldReceive('create')->once()->with($user)->andReturn('token');
57-
$callback = function () {
58-
//
59-
};
6069
$user->shouldReceive('sendPasswordResetNotification')->with('token');
6170

62-
$this->assertEquals(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo'], $callback));
71+
$this->assertEquals(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo']));
6372
}
6473

6574
public function testRedirectIsReturnedByResetWhenUserCredentialsInvalid()
@@ -115,3 +124,11 @@ protected function getMocks()
115124
];
116125
}
117126
}
127+
128+
// Before 7.x we have to check the existence of a new method. In 7.x, this code must be moved to
129+
// Illuminate\Auth\Passwords\TokenRepositoryInterface
130+
131+
interface TestTokenRepositoryInterface extends TokenRepositoryInterface
132+
{
133+
public function recentlyCreated(CanResetPassword $user);
134+
}

0 commit comments

Comments
 (0)