mirror of
https://github.com/flarum/core.git
synced 2025-08-01 14:10:37 +02:00
feat: clear password & email tokens when appropriate (#3567)
* test: password tokens are generated and deleted on password change * chore: delete all password tokens when the password is changed * test: email tokens are generated and deleted on email change * test: email tokens are deleted after password reset * chore: delete email tokens after password change * test: password tokens are deleted after email change * chore: delete password tokens after email change * chore: syntactic sugar * chore: unify event listening
This commit is contained in:
@@ -89,6 +89,7 @@ class SavePasswordController implements RequestHandlerInterface
|
|||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$request->getAttribute('session')->put('errors', new MessageBag($e->errors()));
|
$request->getAttribute('session')->put('errors', new MessageBag($e->errors()));
|
||||||
|
|
||||||
|
// @todo: must return a 422 instead, look into renderable exceptions.
|
||||||
return new RedirectResponse($this->url->to('forum')->route('resetPassword', ['token' => $token->token]));
|
return new RedirectResponse($this->url->to('forum')->route('resetPassword', ['token' => $token->token]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +98,6 @@ class SavePasswordController implements RequestHandlerInterface
|
|||||||
|
|
||||||
$this->dispatchEventsFor($token->user);
|
$this->dispatchEventsFor($token->user);
|
||||||
|
|
||||||
$token->delete();
|
|
||||||
|
|
||||||
$session = $request->getAttribute('session');
|
$session = $request->getAttribute('session');
|
||||||
$accessToken = SessionAccessToken::generate($token->user->id);
|
$accessToken = SessionAccessToken::generate($token->user->id);
|
||||||
$this->authenticator->logIn($session, $accessToken);
|
$this->authenticator->logIn($session, $accessToken);
|
||||||
|
39
framework/core/src/User/TokensClearer.php
Normal file
39
framework/core/src/User/TokensClearer.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Flarum.
|
||||||
|
*
|
||||||
|
* For detailed copyright and license information, please view the
|
||||||
|
* LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Flarum\User;
|
||||||
|
|
||||||
|
use Flarum\User\Event\EmailChanged;
|
||||||
|
use Flarum\User\Event\PasswordChanged;
|
||||||
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
||||||
|
class TokensClearer
|
||||||
|
{
|
||||||
|
public function subscribe(Dispatcher $events): void
|
||||||
|
{
|
||||||
|
$events->listen([PasswordChanged::class, EmailChanged::class], [$this, 'clearPasswordTokens']);
|
||||||
|
$events->listen(PasswordChanged::class, [$this, 'clearEmailTokens']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PasswordChanged|EmailChanged $event
|
||||||
|
*/
|
||||||
|
public function clearPasswordTokens($event): void
|
||||||
|
{
|
||||||
|
$event->user->passwordTokens()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PasswordChanged $event
|
||||||
|
*/
|
||||||
|
public function clearEmailTokens($event): void
|
||||||
|
{
|
||||||
|
$event->user->emailTokens()->delete();
|
||||||
|
}
|
||||||
|
}
|
@@ -721,6 +721,16 @@ class User extends AbstractModel
|
|||||||
return $this->hasMany(EmailToken::class);
|
return $this->hasMany(EmailToken::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the relationship with the user's email tokens.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
*/
|
||||||
|
public function passwordTokens()
|
||||||
|
{
|
||||||
|
return $this->hasMany(PasswordToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define the relationship with the permissions of all of the groups that
|
* Define the relationship with the permissions of all of the groups that
|
||||||
* the user is in.
|
* the user is in.
|
||||||
|
@@ -119,6 +119,7 @@ class UserServiceProvider extends AbstractServiceProvider
|
|||||||
$events->listen(EmailChangeRequested::class, EmailConfirmationMailer::class);
|
$events->listen(EmailChangeRequested::class, EmailConfirmationMailer::class);
|
||||||
|
|
||||||
$events->subscribe(UserMetadataUpdater::class);
|
$events->subscribe(UserMetadataUpdater::class);
|
||||||
|
$events->subscribe(TokensClearer::class);
|
||||||
|
|
||||||
User::registerPreference('discloseOnline', 'boolval', true);
|
User::registerPreference('discloseOnline', 'boolval', true);
|
||||||
User::registerPreference('indexProfile', 'boolval', true);
|
User::registerPreference('indexProfile', 'boolval', true);
|
||||||
|
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Flarum.
|
||||||
|
*
|
||||||
|
* For detailed copyright and license information, please view the
|
||||||
|
* LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Flarum\Tests\integration\api\users;
|
||||||
|
|
||||||
|
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
|
||||||
|
use Flarum\Testing\integration\TestCase;
|
||||||
|
use Flarum\User\EmailToken;
|
||||||
|
use Flarum\User\PasswordToken;
|
||||||
|
|
||||||
|
class PasswordEmailTokensTest extends TestCase
|
||||||
|
{
|
||||||
|
use RetrievesAuthorizedUsers;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->prepareDatabase([
|
||||||
|
'users' => [
|
||||||
|
$this->normalUser(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function actor_has_no_tokens_by_default()
|
||||||
|
{
|
||||||
|
$this->app();
|
||||||
|
|
||||||
|
$this->assertEquals(0, PasswordToken::query()->where('user_id', 2)->count());
|
||||||
|
$this->assertEquals(0, EmailToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function password_tokens_are_generated_when_requesting_password_reset()
|
||||||
|
{
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/forgot', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'email' => 'normal@machine.local'
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(204, $response->getStatusCode());
|
||||||
|
$this->assertEquals(1, PasswordToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function password_tokens_are_deleted_after_password_reset()
|
||||||
|
{
|
||||||
|
$this->app();
|
||||||
|
|
||||||
|
// Request password change to generate a token.
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/forgot', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'email' => 'normal@machine.local'
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Additional Tokens
|
||||||
|
PasswordToken::generate(2)->save();
|
||||||
|
PasswordToken::generate(2)->save();
|
||||||
|
|
||||||
|
$this->assertEquals(204, $response->getStatusCode());
|
||||||
|
$this->assertEquals(3, PasswordToken::query()->where('user_id', 2)->count());
|
||||||
|
|
||||||
|
// Use a token to reset password
|
||||||
|
$response = $this->send(
|
||||||
|
$request = $this->requestWithCsrfToken(
|
||||||
|
$this->request('POST', '/reset', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
])->withParsedBody([
|
||||||
|
'passwordToken' => PasswordToken::query()->latest()->first()->token,
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(302, $response->getStatusCode());
|
||||||
|
$this->assertEquals(0, PasswordToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function email_tokens_are_generated_when_requesting_email_change()
|
||||||
|
{
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('PATCH', '/api/users/2', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'data' => [
|
||||||
|
'attributes' => [
|
||||||
|
'email' => 'new-normal@machine.local'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'password' => 'too-obscure'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$this->assertEquals(1, EmailToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function email_tokens_are_deleted_when_confirming_email()
|
||||||
|
{
|
||||||
|
$this->app();
|
||||||
|
|
||||||
|
EmailToken::generate('new-normal2@machine.local', 2)->save();
|
||||||
|
EmailToken::generate('new-normal3@machine.local', 2)->save();
|
||||||
|
$token = EmailToken::generate('new-normal@machine.local', 2);
|
||||||
|
$token->save();
|
||||||
|
|
||||||
|
$response = $this->send(
|
||||||
|
$this->requestWithCsrfToken(
|
||||||
|
$this->request('POST', '/confirm/'.$token->token, [
|
||||||
|
'authenticatedAs' => 2
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(302, $response->getStatusCode());
|
||||||
|
$this->assertEquals(0, EmailToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function email_tokens_are_deleted_after_password_reset()
|
||||||
|
{
|
||||||
|
$this->app();
|
||||||
|
|
||||||
|
// Request password change to generate a token.
|
||||||
|
$response = $this->send(
|
||||||
|
$this->request('POST', '/api/forgot', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
'json' => [
|
||||||
|
'email' => 'normal@machine.local'
|
||||||
|
]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Additional Tokens
|
||||||
|
EmailToken::generate('new-normal@machine.local', 2)->save();
|
||||||
|
EmailToken::generate('new-normal@machine.local', 2)->save();
|
||||||
|
|
||||||
|
$this->assertEquals(204, $response->getStatusCode());
|
||||||
|
$this->assertEquals(2, EmailToken::query()->where('user_id', 2)->count());
|
||||||
|
|
||||||
|
// Use a token to reset password
|
||||||
|
$response = $this->send(
|
||||||
|
$request = $this->requestWithCsrfToken(
|
||||||
|
$this->request('POST', '/reset', [
|
||||||
|
'authenticatedAs' => 2,
|
||||||
|
])->withParsedBody([
|
||||||
|
'passwordToken' => PasswordToken::query()->latest()->first()->token,
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(302, $response->getStatusCode());
|
||||||
|
$this->assertEquals(0, EmailToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function password_tokens_are_deleted_when_confirming_email()
|
||||||
|
{
|
||||||
|
$this->app();
|
||||||
|
|
||||||
|
PasswordToken::generate(2)->save();
|
||||||
|
PasswordToken::generate(2)->save();
|
||||||
|
|
||||||
|
$token = EmailToken::generate('new-normal@machine.local', 2);
|
||||||
|
$token->save();
|
||||||
|
|
||||||
|
$response = $this->send(
|
||||||
|
$this->requestWithCsrfToken(
|
||||||
|
$this->request('POST', '/confirm/'.$token->token, [
|
||||||
|
'authenticatedAs' => 2
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(302, $response->getStatusCode());
|
||||||
|
$this->assertEquals(0, PasswordToken::query()->where('user_id', 2)->count());
|
||||||
|
}
|
||||||
|
}
|
@@ -14,6 +14,7 @@ use Dflydev\FigCookies\SetCookie;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laminas\Diactoros\CallbackStream;
|
use Laminas\Diactoros\CallbackStream;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,4 +67,15 @@ trait BuildsHttpRequests
|
|||||||
|
|
||||||
return $req->withCookieParams($cookies);
|
return $req->withCookieParams($cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function requestWithCsrfToken(ServerRequestInterface $request): ServerRequestInterface
|
||||||
|
{
|
||||||
|
$initial = $this->send(
|
||||||
|
$this->request('GET', '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
$token = $initial->getHeaderLine('X-CSRF-Token');
|
||||||
|
|
||||||
|
return $this->requestWithCookiesFrom($request->withHeader('X-CSRF-Token', $token), $initial);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user