Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
ChangePasswordAction
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
2 / 2
7
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Auth;
6
7use App\Domain\Auth\Repository\AuthRepository;
8use App\Domain\Auth\Service\PasswordService;
9use App\Domain\Exception\AuthenticationException;
10use App\Domain\Exception\BadRequestException;
11use App\Domain\Exception\ValidationException;
12use App\Renderer\JsonRenderer;
13use App\Support\Row;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16
17/**
18 * POST /api/auth/change-password
19 *
20 * FSC-105: lets an authenticated investor change their own password
21 * without going through the email-driven forgot/reset flow. Requires
22 * the current password for re-authentication (defense in depth — a
23 * stolen JWT alone can't change credentials).
24 *
25 * Body: { currentPassword, newPassword }
26 */
27final readonly class ChangePasswordAction
28{
29    public function __construct(
30        private AuthRepository $authRepository,
31        private PasswordService $passwordService,
32        private JsonRenderer $renderer,
33    ) {}
34
35    public function __invoke(
36        ServerRequestInterface $request,
37        ResponseInterface $response,
38    ): ResponseInterface {
39        $userId = Row::int($request->getAttributes(), 'userId');
40
41        $data = (array)$request->getParsedBody();
42        $currentPassword = Row::nullableString($data, 'currentPassword') ?? '';
43        $newPassword = Row::nullableString($data, 'newPassword') ?? '';
44
45        if ($currentPassword === '') {
46            throw new BadRequestException('Current password is required');
47        }
48        if ($newPassword === '') {
49            throw new BadRequestException('New password is required');
50        }
51
52        // Re-verify the current password against the stored hash. Defense
53        // in depth: a stolen access token alone is insufficient to change
54        // the password — the user must know the existing one.
55        $storedHash = $this->authRepository->getPasswordHash($userId);
56        if ($storedHash === null || !$this->passwordService->verifyPassword($currentPassword, $storedHash)) {
57            throw new AuthenticationException('Current password is incorrect');
58        }
59
60        if ($currentPassword === $newPassword) {
61            throw new ValidationException('New password must be different from the current password');
62        }
63
64        $this->passwordService->validateStrength($newPassword);
65
66        $newHash = $this->passwordService->hashPassword($newPassword);
67        $this->authRepository->updatePassword($userId, $newHash);
68
69        return $this->renderer->json($response, [
70            'success' => true,
71            'message' => 'Password updated successfully.',
72        ]);
73    }
74}