Skip to content

Commit 6d3dcc0

Browse files
TatevikGrtatevikg1
andauthored
Password reset (#154)
* Password reset endpoints * Tests --------- Co-authored-by: Tatevik <[email protected]>
1 parent bf11b81 commit 6d3dcc0

File tree

9 files changed

+424
-0
lines changed

9 files changed

+424
-0
lines changed

config/services/managers.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,6 @@ services:
5252
autowire: true
5353
autoconfigure: true
5454

55+
PhpList\Core\Domain\Identity\Service\PasswordManager:
56+
autowire: true
57+
autoconfigure: true
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Identity\Controller;
6+
7+
use OpenApi\Attributes as OA;
8+
use PhpList\Core\Domain\Identity\Service\PasswordManager;
9+
use PhpList\Core\Security\Authentication;
10+
use PhpList\RestBundle\Common\Controller\BaseController;
11+
use PhpList\RestBundle\Common\Validator\RequestValidator;
12+
use PhpList\RestBundle\Identity\Request\RequestPasswordResetRequest;
13+
use PhpList\RestBundle\Identity\Request\ResetPasswordRequest;
14+
use PhpList\RestBundle\Identity\Request\ValidateTokenRequest;
15+
use Symfony\Component\HttpFoundation\JsonResponse;
16+
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\Routing\Attribute\Route;
19+
20+
/**
21+
* This controller provides methods to reset admin passwords.
22+
*/
23+
#[Route('/password-reset', name: 'password_reset_')]
24+
class PasswordResetController extends BaseController
25+
{
26+
private PasswordManager $passwordManager;
27+
28+
public function __construct(
29+
Authentication $authentication,
30+
RequestValidator $validator,
31+
PasswordManager $passwordManager,
32+
) {
33+
parent::__construct($authentication, $validator);
34+
35+
$this->passwordManager = $passwordManager;
36+
}
37+
38+
#[Route('/request', name: 'request', methods: ['POST'])]
39+
#[OA\Post(
40+
path: '/api/v2/password-reset/request',
41+
description: 'Request a password reset token for an administrator account.',
42+
summary: 'Request a password reset.',
43+
requestBody: new OA\RequestBody(
44+
description: 'Administrator email',
45+
required: true,
46+
content: new OA\JsonContent(
47+
required: ['email'],
48+
properties: [
49+
new OA\Property(property: 'email', type: 'string', format: 'email', example: '[email protected]'),
50+
]
51+
)
52+
),
53+
tags: ['password-reset'],
54+
responses: [
55+
new OA\Response(
56+
response: 204,
57+
description: 'Password reset token generated',
58+
),
59+
new OA\Response(
60+
response: 400,
61+
description: 'Failure',
62+
content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse')
63+
),
64+
new OA\Response(
65+
response: 404,
66+
description: 'Failure',
67+
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse')
68+
)
69+
]
70+
)]
71+
public function requestPasswordReset(Request $request): JsonResponse
72+
{
73+
/** @var RequestPasswordResetRequest $resetRequest */
74+
$resetRequest = $this->validator->validate($request, RequestPasswordResetRequest::class);
75+
76+
$this->passwordManager->generatePasswordResetToken($resetRequest->email);
77+
78+
return $this->json(null, Response::HTTP_NO_CONTENT);
79+
}
80+
81+
#[Route('/validate', name: 'validate', methods: ['POST'])]
82+
#[OA\Post(
83+
path: '/api/v2/password-reset/validate',
84+
description: 'Validate a password reset token.',
85+
summary: 'Validate a password reset token.',
86+
requestBody: new OA\RequestBody(
87+
description: 'Password reset token',
88+
required: true,
89+
content: new OA\JsonContent(
90+
required: ['token'],
91+
properties: [
92+
new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'),
93+
]
94+
)
95+
),
96+
tags: ['password-reset'],
97+
responses: [
98+
new OA\Response(
99+
response: 200,
100+
description: 'Success',
101+
content: new OA\JsonContent(
102+
properties: [
103+
new OA\Property(property: 'valid', type: 'boolean', example: true),
104+
]
105+
)
106+
),
107+
new OA\Response(
108+
response: 400,
109+
description: 'Failure',
110+
content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse')
111+
)
112+
]
113+
)]
114+
public function validateToken(Request $request): JsonResponse
115+
{
116+
/** @var ValidateTokenRequest $validateRequest */
117+
$validateRequest = $this->validator->validate($request, ValidateTokenRequest::class);
118+
119+
$administrator = $this->passwordManager->validatePasswordResetToken($validateRequest->token);
120+
121+
return $this->json([ 'valid' => $administrator !== null]);
122+
}
123+
124+
#[Route('/reset', name: 'reset', methods: ['POST'])]
125+
#[OA\Post(
126+
path: '/api/v2/password-reset/reset',
127+
description: 'Reset an administrator password using a token.',
128+
summary: 'Reset password with token.',
129+
requestBody: new OA\RequestBody(
130+
description: 'Password reset information',
131+
required: true,
132+
content: new OA\JsonContent(
133+
required: ['token', 'newPassword'],
134+
properties: [
135+
new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'),
136+
new OA\Property(
137+
property: 'newPassword',
138+
type: 'string',
139+
format: 'password',
140+
example: 'newSecurePassword123',
141+
),
142+
]
143+
)
144+
),
145+
tags: ['password-reset'],
146+
responses: [
147+
new OA\Response(
148+
response: 200,
149+
description: 'Success',
150+
content: new OA\JsonContent(
151+
properties: [
152+
new OA\Property(property: 'message', type: 'string', example: 'Password updated successfully'),
153+
]
154+
)
155+
),
156+
new OA\Response(
157+
response: 400,
158+
description: 'Invalid or expired token',
159+
content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse')
160+
)
161+
]
162+
)]
163+
public function resetPassword(Request $request): JsonResponse
164+
{
165+
/** @var ResetPasswordRequest $resetRequest */
166+
$resetRequest = $this->validator->validate($request, ResetPasswordRequest::class);
167+
168+
$success = $this->passwordManager->updatePasswordWithToken(
169+
$resetRequest->token,
170+
$resetRequest->newPassword
171+
);
172+
173+
if ($success) {
174+
return $this->json([ 'message' => 'Password updated successfully']);
175+
}
176+
177+
return $this->json(['message' => 'Invalid or expired token'], Response::HTTP_BAD_REQUEST);
178+
}
179+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Identity\Request;
6+
7+
use PhpList\RestBundle\Common\Request\RequestInterface;
8+
use Symfony\Component\Validator\Constraints as Assert;
9+
10+
class RequestPasswordResetRequest implements RequestInterface
11+
{
12+
#[Assert\NotBlank]
13+
#[Assert\Email]
14+
#[Assert\Type(type: 'string')]
15+
public string $email;
16+
17+
public function getDto(): RequestPasswordResetRequest
18+
{
19+
return $this;
20+
}
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Identity\Request;
6+
7+
use PhpList\RestBundle\Common\Request\RequestInterface;
8+
use Symfony\Component\Validator\Constraints as Assert;
9+
10+
class ResetPasswordRequest implements RequestInterface
11+
{
12+
#[Assert\NotBlank]
13+
#[Assert\Type(type: 'string')]
14+
public string $token;
15+
16+
#[Assert\NotBlank]
17+
#[Assert\Type(type: 'string')]
18+
#[Assert\Length(min: 8)]
19+
public string $newPassword;
20+
21+
public function getDto(): ResetPasswordRequest
22+
{
23+
return $this;
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Identity\Request;
6+
7+
use PhpList\RestBundle\Common\Request\RequestInterface;
8+
use Symfony\Component\Validator\Constraints as Assert;
9+
10+
class ValidateTokenRequest implements RequestInterface
11+
{
12+
#[Assert\NotBlank]
13+
#[Assert\Type(type: 'string')]
14+
public string $token;
15+
16+
public function getDto(): ValidateTokenRequest
17+
{
18+
return $this;
19+
}
20+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Integration\Identity\Controller;
6+
7+
use PhpList\RestBundle\Identity\Controller\PasswordResetController;
8+
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
9+
use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture;
10+
11+
class PasswordResetControllerTest extends AbstractTestController
12+
{
13+
public function testControllerIsAvailableViaContainer(): void
14+
{
15+
self::assertInstanceOf(
16+
PasswordResetController::class,
17+
self::getContainer()->get(PasswordResetController::class)
18+
);
19+
}
20+
21+
public function testRequestPasswordResetWithNoJsonReturnsError400(): void
22+
{
23+
$this->jsonRequest('post', '/api/v2/password-reset/request');
24+
25+
$this->assertHttpBadRequest();
26+
$data = $this->getDecodedJsonResponseContent();
27+
$this->assertStringContainsString('Invalid JSON:', $data['message']);
28+
}
29+
30+
public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void
31+
{
32+
$jsonData = json_encode(['email' => 'not-an-email']);
33+
$this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData);
34+
35+
$this->assertHttpUnprocessableEntity();
36+
$data = $this->getDecodedJsonResponseContent();
37+
$this->assertStringContainsString('This value is not a valid email address', $data['message']);
38+
}
39+
40+
public function testRequestPasswordResetWithNonExistentEmailReturnsError404(): void
41+
{
42+
$this->loadFixtures([AdministratorFixture::class]);
43+
$jsonData = json_encode(['email' => '[email protected]']);
44+
$this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData);
45+
46+
$this->assertHttpNotFound();
47+
}
48+
49+
public function testRequestPasswordResetWithValidEmailReturnsSuccess(): void
50+
{
51+
$this->loadFixtures([AdministratorFixture::class]);
52+
$jsonData = json_encode(['email' => '[email protected]']);
53+
$this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData);
54+
55+
$this->assertHttpNoContent();
56+
}
57+
58+
public function testValidateTokenWithNoJsonReturnsError400(): void
59+
{
60+
$this->jsonRequest('post', '/api/v2/password-reset/validate');
61+
62+
$this->assertHttpBadRequest();
63+
$data = $this->getDecodedJsonResponseContent();
64+
$this->assertStringContainsString('Invalid JSON:', $data['message']);
65+
}
66+
67+
public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void
68+
{
69+
$this->loadFixtures([AdministratorFixture::class]);
70+
$jsonData = json_encode(['token' => 'invalid-token']);
71+
$this->jsonRequest('post', '/api/v2/password-reset/validate', [], [], [], $jsonData);
72+
73+
$this->assertHttpOkay();
74+
$data = $this->getDecodedJsonResponseContent();
75+
$this->assertFalse($data['valid']);
76+
}
77+
78+
public function testResetPasswordWithNoJsonReturnsError400(): void
79+
{
80+
$this->jsonRequest('post', '/api/v2/password-reset/reset');
81+
82+
$this->assertHttpBadRequest();
83+
$data = $this->getDecodedJsonResponseContent();
84+
$this->assertStringContainsString('Invalid JSON:', $data['message']);
85+
}
86+
87+
public function testResetPasswordWithInvalidTokenReturnsBadRequest(): void
88+
{
89+
$this->loadFixtures([AdministratorFixture::class]);
90+
$jsonData = json_encode(['token' => 'invalid-token', 'newPassword' => 'newPassword123']);
91+
$this->jsonRequest('post', '/api/v2/password-reset/reset', [], [], [], $jsonData);
92+
93+
$this->assertHttpBadRequest();
94+
$data = $this->getDecodedJsonResponseContent();
95+
$this->assertEquals('Invalid or expired token', $data['message']);
96+
}
97+
98+
public function testResetPasswordWithShortPasswordReturnsError422(): void
99+
{
100+
$this->loadFixtures([AdministratorFixture::class]);
101+
$jsonData = json_encode(['token' => 'valid-token', 'newPassword' => 'short']);
102+
$this->jsonRequest('post', '/api/v2/password-reset/reset', [], [], [], $jsonData);
103+
104+
$this->assertHttpUnprocessableEntity();
105+
$data = $this->getDecodedJsonResponseContent();
106+
$this->assertStringContainsString('This value is too short', $data['message']);
107+
}
108+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\RestBundle\Tests\Unit\Identity\Request;
6+
7+
use PhpList\RestBundle\Identity\Request\RequestPasswordResetRequest;
8+
use PHPUnit\Framework\TestCase;
9+
10+
class RequestPasswordResetRequestTest extends TestCase
11+
{
12+
public function testGetDtoReturnsSelf(): void
13+
{
14+
$request = new RequestPasswordResetRequest();
15+
$request->email = '[email protected]';
16+
17+
$dto = $request->getDto();
18+
19+
$this->assertSame($request, $dto);
20+
$this->assertEquals('[email protected]', $dto->email);
21+
}
22+
}

0 commit comments

Comments
 (0)