Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.27% covered (warning)
84.27%
75 / 89
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailChangeService
84.27% covered (warning)
84.27%
75 / 89
37.50% covered (danger)
37.50%
3 / 8
27.43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestChange
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
6.01
 confirmChange
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
6.42
 validateEmail
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 assertEmailAvailable
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 notifyOldAddress
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
2.08
 buildEmailHtml
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildChangedNoticeHtml
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Auth\Repository\AuthRepository;
8use App\Domain\Exception\AuthenticationException;
9use App\Domain\Exception\BadRequestException;
10use App\Domain\Exception\ConflictException;
11use App\Domain\Exception\ValidationException;
12use App\Domain\Investor\Repository\InvestorRepository;
13use App\Domain\Notification\Data\EmailMessage;
14use App\Domain\Notification\Service\EmailServiceInterface;
15use DateTimeImmutable;
16use PDO;
17use Psr\Log\LoggerInterface;
18use RuntimeException;
19use Throwable;
20
21use const FILTER_VALIDATE_EMAIL;
22
23use function bin2hex;
24use function filter_var;
25use function random_bytes;
26use function sprintf;
27use function strlen;
28use function strtolower;
29
30use function trim;
31
32/**
33 * FSC-86: verified self-service email change.
34 *
35 * A customer requests a new address (re-authenticating with their current
36 * password); we email a verification link to the *new* address and only apply
37 * the change once they click it. The change updates both users.email (login /
38 * password-reset record) and investors.email (contact/profile) atomically, so
39 * the two never drift. Accounts are keyed by investor_id and are untouched.
40 */
41final readonly class EmailChangeService
42{
43    private const int TOKEN_EXPIRY_HOURS = 1;
44
45    public function __construct(
46        private PDO $pdo,
47        private AuthRepository $authRepository,
48        private InvestorRepository $investorRepository,
49        private PasswordService $passwordService,
50        private EmailServiceInterface $emailService,
51        private LoggerInterface $logger,
52    ) {}
53
54    /**
55     * Validate the request, re-authenticate, and email a verification link to
56     * the new address. Does NOT change any email yet.
57     *
58     * @param string $appUrl Base URL for the verification link (no trailing slash).
59     * @param int $userId
60     * @param ?int $investorId
61     * @param string $currentPassword
62     * @param string $newEmail
63     */
64    public function requestChange(
65        int $userId,
66        ?int $investorId,
67        string $currentPassword,
68        string $newEmail,
69        string $appUrl,
70    ): void {
71        if ($currentPassword === '') {
72            throw new BadRequestException('Current password is required');
73        }
74
75        $newEmail = strtolower(trim($newEmail));
76        $this->validateEmail($newEmail);
77
78        $user = $this->authRepository->findUserById($userId);
79
80        if ($user === null) {
81            throw new AuthenticationException('User not found');
82        }
83
84        // Re-verify the current password. Defense in depth: a stolen access
85        // token alone must not be enough to redirect the account's email.
86        $storedHash = $this->authRepository->getPasswordHash($userId);
87
88        if ($storedHash === null || !$this->passwordService->verifyPassword($currentPassword, $storedHash)) {
89            throw new AuthenticationException('Current password is incorrect');
90        }
91
92        if (strtolower(trim($user->email)) === $newEmail) {
93            throw new ValidationException('The new email must be different from your current email');
94        }
95
96        $this->assertEmailAvailable($newEmail, $userId, $investorId);
97
98        $token = bin2hex(random_bytes(32));
99        $expiresAt = new DateTimeImmutable('+' . self::TOKEN_EXPIRY_HOURS . ' hour');
100        $this->authRepository->storeEmailChangeToken($userId, $newEmail, $token, $expiresAt);
101
102        $confirmUrl = sprintf('%s/confirm-email?token=%s', $appUrl, $token);
103
104        $this->emailService->send(new EmailMessage(
105            to: $newEmail,
106            subject: 'FlowState Capital - Confirm Your New Email Address',
107            bodyHtml: $this->buildEmailHtml($user->username, $confirmUrl),
108            bodyText: sprintf(
109                "Hi %s,\n\nYou requested to change the email address on your FlowState Capital account to this one. "
110                . "Confirm the change within %d hour by visiting:\n\n%s\n\n"
111                . "If you didn't request this, you can ignore this email and your address will stay the same.\n\nFlowState Capital",
112                $user->username,
113                self::TOKEN_EXPIRY_HOURS,
114                $confirmUrl,
115            ),
116        ));
117    }
118
119    /**
120     * Consume a verification token and apply the email change to both the
121     * users (login) and investors (profile) records atomically.
122     * @param string $token
123     */
124    public function confirmChange(string $token): void
125    {
126        if ($token === '') {
127            throw new BadRequestException('Verification token is required');
128        }
129
130        $record = $this->authRepository->findValidEmailChangeToken($token);
131
132        if ($record === null) {
133            throw new BadRequestException('This email confirmation link is invalid or has expired');
134        }
135
136        $userId = $record['userId'];
137        $newEmail = $record['newEmail'];
138
139        $user = $this->authRepository->findUserById($userId);
140
141        if ($user === null) {
142            throw new BadRequestException('This email confirmation link is invalid or has expired');
143        }
144
145        // Re-check availability at confirm time â€” another account may have
146        // claimed the address between request and confirmation.
147        $this->assertEmailAvailable($newEmail, $userId, $user->investorId);
148
149        $oldEmail = $user->email;
150
151        $this->pdo->beginTransaction();
152
153        try {
154            $this->authRepository->updateUserEmail($userId, $newEmail);
155
156            if ($user->investorId !== null) {
157                $this->investorRepository->updateEmailById($user->investorId, $newEmail);
158            }
159
160            $this->authRepository->markEmailChangeTokenUsed($record['tokenId']);
161
162            $this->pdo->commit();
163        } catch (Throwable $e) {
164            $this->pdo->rollBack();
165
166            throw $e;
167        }
168
169        // Best-effort heads-up to the old address so an unauthorized change is
170        // noticed. Never block the (already committed) change on delivery.
171        $this->notifyOldAddress($user->username, $oldEmail, $newEmail);
172    }
173
174    private function validateEmail(string $email): void
175    {
176        if ($email === '') {
177            throw new BadRequestException('A new email address is required');
178        }
179
180        if (strlen($email) > 255) {
181            throw new ValidationException('Email cannot exceed 255 characters');
182        }
183
184        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
185            throw new ValidationException('Invalid email format');
186        }
187    }
188
189    private function assertEmailAvailable(string $email, int $excludeUserId, ?int $excludeInvestorId): void
190    {
191        if ($this->authRepository->isEmailTakenByAnotherUser($email, $excludeUserId)) {
192            throw new ConflictException('That email address is already in use');
193        }
194
195        if (
196            $excludeInvestorId !== null
197            && $this->investorRepository->isEmailTakenByAnotherInvestor($email, $excludeInvestorId)
198        ) {
199            throw new ConflictException('That email address is already in use');
200        }
201    }
202
203    private function notifyOldAddress(string $username, string $oldEmail, string $newEmail): void
204    {
205        try {
206            $this->emailService->send(new EmailMessage(
207                to: $oldEmail,
208                subject: 'FlowState Capital - Your Email Address Was Changed',
209                bodyHtml: $this->buildChangedNoticeHtml($username, $newEmail),
210                bodyText: sprintf(
211                    "Hi %s,\n\nThe email address on your FlowState Capital account was just changed to %s. "
212                    . "If you made this change, no action is needed. If you did NOT, please contact support immediately.\n\nFlowState Capital",
213                    $username,
214                    $newEmail,
215                ),
216            ));
217        } catch (Throwable $e) {
218            $this->logger->warning('Failed to send email-change notice to old address', [
219                'error' => $e->getMessage(),
220            ]);
221        }
222    }
223
224    private function buildEmailHtml(string $username, string $confirmUrl): string
225    {
226        return <<<HTML
227            <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
228                <h2 style="color: #1976d2;">FlowState Capital</h2>
229                <p>Hi {$username},</p>
230                <p>You requested to change the email address on your account to this one. Click the button below to confirm:</p>
231                <p style="text-align: center; margin: 30px 0;">
232                    <a href="{$confirmUrl}"
233                       style="background-color: #1976d2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">
234                        Confirm New Email
235                    </a>
236                </p>
237                <p style="color: #666; font-size: 14px;">
238                    This link expires in 1 hour. If you didn't request this, you can safely ignore this email and your address will stay the same.
239                </p>
240                <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
241                <p style="color: #999; font-size: 12px;">FlowState Capital LLC</p>
242            </div>
243            HTML;
244    }
245
246    private function buildChangedNoticeHtml(string $username, string $newEmail): string
247    {
248        return <<<HTML
249            <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
250                <h2 style="color: #1976d2;">FlowState Capital</h2>
251                <p>Hi {$username},</p>
252                <p>The email address on your account was just changed to <strong>{$newEmail}</strong>.</p>
253                <p style="color: #666; font-size: 14px;">
254                    If you made this change, no action is needed. If you did <strong>not</strong> request this,
255                    please contact support immediately.
256                </p>
257                <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
258                <p style="color: #999; font-size: 12px;">FlowState Capital LLC</p>
259            </div>
260            HTML;
261    }
262}