Skip to content

Commit 1dcfc58

Browse files
authored
[2.x] fix: enforce expiry check on password reset token submission (#4550)
* [2.x] fix: enforce expiry check on password reset token submission * fix phpstan: add @method annotation for validOrFail scope * fix: use static method instead of scope for validOrFail to satisfy phpstan
1 parent 120f10f commit 1dcfc58

3 files changed

Lines changed: 96 additions & 1 deletion

File tree

framework/core/src/Forum/Controller/SavePasswordController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function handle(Request $request): ResponseInterface
4242
{
4343
$input = $request->getParsedBody();
4444

45-
$token = PasswordToken::findOrFail(Arr::get($input, 'passwordToken'));
45+
$token = PasswordToken::validOrFail(Arr::get($input, 'passwordToken'));
4646

4747
$password = Arr::get($input, 'password');
4848

framework/core/src/User/PasswordToken.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use Carbon\Carbon;
1313
use Flarum\Database\AbstractModel;
14+
use Flarum\User\Exception\InvalidConfirmationTokenException;
1415
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1516
use Illuminate\Support\Str;
1617

@@ -44,6 +45,23 @@ public static function generate(int $userId): static
4445
return $token;
4546
}
4647

48+
/**
49+
* Find the token with the given ID, and assert that it has not expired.
50+
*
51+
* @throws InvalidConfirmationTokenException
52+
*/
53+
public static function validOrFail(string $id): static
54+
{
55+
/** @var static|null $token */
56+
$token = static::find($id);
57+
58+
if (! $token || $token->created_at->diffInDays(null, true) >= 1) {
59+
throw new InvalidConfirmationTokenException;
60+
}
61+
62+
return $token;
63+
}
64+
4765
/**
4866
* @return BelongsTo<User, $this>
4967
*/
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\api\users;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
14+
use Flarum\Testing\integration\TestCase;
15+
use Flarum\User\PasswordToken;
16+
use Flarum\User\User;
17+
use PHPUnit\Framework\Attributes\Test;
18+
19+
class PasswordTokenExpiryTest extends TestCase
20+
{
21+
use RetrievesAuthorizedUsers;
22+
23+
protected function setUp(): void
24+
{
25+
parent::setUp();
26+
27+
$this->prepareDatabase([
28+
User::class => [
29+
$this->normalUser(),
30+
],
31+
]);
32+
}
33+
34+
#[Test]
35+
public function valid_password_reset_token_is_accepted(): void
36+
{
37+
$this->app();
38+
39+
$token = PasswordToken::generate(2);
40+
$token->save();
41+
42+
$response = $this->send(
43+
$this->requestWithCsrfToken(
44+
$this->request('POST', '/reset')->withParsedBody([
45+
'passwordToken' => $token->token,
46+
'password' => 'new-password',
47+
'password_confirmation' => 'new-password',
48+
])
49+
)
50+
);
51+
52+
$this->assertEquals(302, $response->getStatusCode());
53+
}
54+
55+
#[Test]
56+
public function expired_password_reset_token_is_rejected(): void
57+
{
58+
$this->app();
59+
60+
$token = PasswordToken::generate(2);
61+
$token->created_at = Carbon::now()->subDays(2);
62+
$token->save();
63+
64+
$response = $this->send(
65+
$this->requestWithCsrfToken(
66+
$this->request('POST', '/reset')->withParsedBody([
67+
'passwordToken' => $token->token,
68+
'password' => 'new-password',
69+
'password_confirmation' => 'new-password',
70+
])
71+
)
72+
);
73+
74+
// Should be rejected — currently findOrFail accepts expired tokens
75+
$this->assertNotEquals(302, $response->getStatusCode());
76+
}
77+
}

0 commit comments

Comments
 (0)