Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.26% covered (success)
96.26%
206 / 214
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserMfaDeviceService
96.24% covered (success)
96.24%
205 / 213
78.57% covered (warning)
78.57%
11 / 14
40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 hasActiveMfa
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getUserDevices
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 startEnrollment
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 confirmEnrollment
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
5
 revokeDevice
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
4
 setDefaultDevice
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
7.77
 getDefaultDevice
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 verifyUserMfa
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 hasExistingRecoveryCodes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 generateAndStoreRecoveryCodes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 verifyAgainstTotpDevices
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 verifyAgainstRecoveryCode
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 syncUserMfaStatus
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Mfa\MfaTotpService;
12use App\Core\Security\Mfa\MfaTotpServiceInterface;
13use App\Core\Security\Mfa\QrCodeSvgRenderer;
14use App\Core\Security\Mfa\QrCodeSvgRendererInterface;
15use App\Modules\User\Domain\Repository\UserRepositoryInterface;
16use DateTimeImmutable;
17use PDO;
18
19/**
20 * Enterprise User MFA Device Management Service.
21 *
22 * Implements multi-device TOTP authenticator registration, pure PHP vector QR rendering,
23 * anti-replay token validation, and single-use emergency recovery code failover.
24 *
25 * @package App\Modules\User\Application\Service
26 */
27final readonly class UserMfaDeviceService implements UserMfaDeviceServiceInterface
28{
29    private MfaTotpServiceInterface $totpService;
30    private QrCodeSvgRendererInterface $qrRenderer;
31    private string $tableTokens;
32    private string $tableCodes;
33    private string $tableUsers;
34
35    /**
36     * UserMfaDeviceService constructor.
37     *
38     * @param PDO $pdo Database connection.
39     * @param UserRepositoryInterface $userRepository User repository.
40     * @param MfaTotpServiceInterface|null $totpService Optional TOTP engine service.
41     * @param QrCodeSvgRendererInterface|null $qrRenderer Optional SVG QR renderer.
42     * @param string $tablePrefix Database table prefix (default 'a_').
43     */
44    public function __construct(
45        private PDO $pdo,
46        private UserRepositoryInterface $userRepository,
47        ?MfaTotpServiceInterface $totpService = null,
48        ?QrCodeSvgRendererInterface $qrRenderer = null,
49        private string $tablePrefix = 'a_'
50    ) {
51        $this->totpService = $totpService ?? new MfaTotpService();
52        $this->qrRenderer = $qrRenderer ?? new QrCodeSvgRenderer();
53        $this->tableTokens = $this->tablePrefix . 'mod_user_mfa_tokens';
54        $this->tableCodes = $this->tablePrefix . 'mod_user_mfa_recovery_codes';
55        $this->tableUsers = $this->tablePrefix . 'mod_users_records';
56    }
57
58    /** {@inheritdoc} */
59    public function hasActiveMfa(int $userId): bool
60    {
61        $stmt = $this->pdo->prepare(
62            "SELECT COUNT(*) FROM `{$this->tableTokens}` WHERE `user_id` = :uid AND `is_active` = 1"
63        );
64        $stmt->execute([':uid' => $userId]);
65        return ((int) $stmt->fetchColumn()) > 0;
66    }
67
68    /** {@inheritdoc} */
69    public function getUserDevices(int $userId): array
70    {
71        $stmt = $this->pdo->prepare(
72            'SELECT id, device_name, created_at, last_used_at, is_active, is_default ' .
73            "FROM `{$this->tableTokens}` WHERE `user_id` = :uid ORDER BY is_default DESC, is_active DESC, id DESC"
74        );
75        $stmt->execute([':uid' => $userId]);
76        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
77
78        $result = [];
79        foreach ($rows as $row) {
80            $result[] = [
81                'id'           => (int) ($row['id'] ?? 0),
82                'device_name'  => (string) ($row['device_name'] ?? ''),
83                'created_at'   => (string) ($row['created_at'] ?? date('Y-m-d H:i:s')),
84                'last_used_at' => isset($row['last_used_at']) ? (string) $row['last_used_at'] : null,
85                'is_active'    => (int) ($row['is_active'] ?? 1),
86                'is_default'   => (int) ($row['is_default'] ?? 0),
87            ];
88        }
89
90        return $result;
91    }
92
93    /** {@inheritdoc} */
94    public function startEnrollment(int $userId, string $deviceName, string $issuer = 'Ammonly'): array
95    {
96        $user = $this->userRepository->findById($userId);
97        $account = $user !== null ? $user->getEmail() : 'user-' . $userId;
98
99        $secret = $this->totpService->generateSecret(20);
100        $otpauthUri = $this->totpService->buildOtpAuthUri(
101            accountName: $account,
102            secret: $secret,
103            issuer: $issuer,
104            digits: 6,
105            period: 30
106        );
107
108        $qrSvg = $this->qrRenderer->renderSvg($otpauthUri, 220);
109
110        return [
111            'secret'      => $secret,
112            'otpauth_uri' => $otpauthUri,
113            'qr_svg'      => $qrSvg,
114            'device_name' => $deviceName,
115        ];
116    }
117
118    /** {@inheritdoc} */
119    public function confirmEnrollment(
120        int $userId,
121        string $deviceName,
122        string $secret,
123        string $verificationCode
124    ): array {
125        $matchedStep = $this->totpService->verifyCode(
126            code: $verificationCode,
127            secret: $secret,
128            lastUsedStep: null,
129            window: 1
130        );
131
132        if ($matchedStep === null) {
133            return [
134                'success' => false,
135                'message' => 'The provided TOTP verification code is invalid or expired.',
136            ];
137        }
138
139        $encryptedSecret = $this->totpService->encryptSecret($secret);
140        $cleanLabel = trim($deviceName) !== '' ? trim($deviceName) : 'Authenticator Device';
141        $isFirstDevice = !$this->hasActiveMfa($userId);
142        $isDefault = $isFirstDevice ? 1 : 0;
143
144        $insertSql = "INSERT INTO `{$this->tableTokens}" .
145            '(`user_id`, `device_name`, `secret_encrypted`, `algorithm`, `digits`, `period`, ' .
146            '`last_used_step`, `is_active`, `is_default`, `last_used_at`) ' .
147            'VALUES (:uid, :dname, :sec, \'sha1\', 6, 30, :step, 1, :is_default, NOW())';
148
149        $stmt = $this->pdo->prepare($insertSql);
150        $stmt->execute([
151            ':uid'        => $userId,
152            ':dname'      => $cleanLabel,
153            ':sec'        => $encryptedSecret,
154            ':step'       => $matchedStep,
155            ':is_default' => $isDefault,
156        ]);
157
158        $this->syncUserMfaStatus($userId);
159
160        $recoveryCodes = null;
161        if (!$this->hasExistingRecoveryCodes($userId)) {
162            $recoveryCodes = $this->generateAndStoreRecoveryCodes($userId);
163        }
164
165        return [
166            'success'        => true,
167            'recovery_codes' => $recoveryCodes,
168            'message'        => 'MFA device has been successfully registered.',
169        ];
170    }
171
172    /** {@inheritdoc} */
173    public function revokeDevice(int $userId, int $deviceId): bool
174    {
175        $checkStmt = $this->pdo->prepare(
176            "SELECT is_default FROM `{$this->tableTokens}` WHERE `id` = :did AND `user_id` = :uid"
177        );
178        $checkStmt->execute([':did' => $deviceId, ':uid' => $userId]);
179        $deviceRow = $checkStmt->fetch(PDO::FETCH_ASSOC);
180        if ($deviceRow === false) {
181            return false;
182        }
183
184        $wasDefault = (int) ($deviceRow['is_default'] ?? 0) === 1;
185
186        $stmt = $this->pdo->prepare(
187            "UPDATE `{$this->tableTokens}` SET `is_active` = 0, `is_default` = 0, `revoked_at` = NOW() " .
188            'WHERE `id` = :did AND `user_id` = :uid'
189        );
190        $stmt->execute([':did' => $deviceId, ':uid' => $userId]);
191        $revoked = $stmt->rowCount() > 0;
192
193        if ($revoked) {
194            if ($wasDefault) {
195                $promoteStmt = $this->pdo->prepare(
196                    "UPDATE `{$this->tableTokens}` SET `is_default` = 1 " .
197                    'WHERE `user_id` = :uid AND `is_active` = 1 ORDER BY id DESC LIMIT 1'
198                );
199                $promoteStmt->execute([':uid' => $userId]);
200            }
201            $this->syncUserMfaStatus($userId);
202        }
203
204        return $revoked;
205    }
206
207    /** {@inheritdoc} */
208    public function setDefaultDevice(int $userId, int $deviceId): bool
209    {
210        $checkStmt = $this->pdo->prepare(
211            "SELECT id FROM `{$this->tableTokens}` WHERE `id` = :did AND `user_id` = :uid AND `is_active` = 1"
212        );
213        $checkStmt->execute([':did' => $deviceId, ':uid' => $userId]);
214        if ($checkStmt->fetchColumn() === false) {
215            return false;
216        }
217
218        $ownsTx = !$this->pdo->inTransaction();
219        if ($ownsTx) {
220            $this->pdo->beginTransaction();
221        }
222        try {
223            $resetStmt = $this->pdo->prepare(
224                "UPDATE `{$this->tableTokens}` SET `is_default` = 0 WHERE `user_id` = :uid"
225            );
226            $resetStmt->execute([':uid' => $userId]);
227
228            $setStmt = $this->pdo->prepare(
229                "UPDATE `{$this->tableTokens}` SET `is_default` = 1 WHERE `id` = :did AND `user_id` = :uid"
230            );
231            $setStmt->execute([':did' => $deviceId, ':uid' => $userId]);
232
233            if ($ownsTx) {
234                $this->pdo->commit();
235            }
236            return true;
237        } catch (\Throwable $e) {
238            if ($ownsTx && $this->pdo->inTransaction()) {
239                $this->pdo->rollBack();
240            }
241            throw $e;
242        }
243    }
244
245    /** {@inheritdoc} */
246    public function getDefaultDevice(int $userId): ?array
247    {
248        $stmt = $this->pdo->prepare(
249            'SELECT id, device_name, created_at, last_used_at, is_active, is_default ' .
250            "FROM `{$this->tableTokens}` WHERE `user_id` = :uid AND `is_active` = 1 AND `is_default` = 1 LIMIT 1"
251        );
252        $stmt->execute([':uid' => $userId]);
253        /** @var array<string, mixed>|false $row */
254        $row = $stmt->fetch(PDO::FETCH_ASSOC);
255        if ($row === false) {
256            return null;
257        }
258
259        return [
260            'id'           => (int) $row['id'],
261            'device_name'  => (string) ($row['device_name'] ?? ''),
262            'created_at'   => (string) ($row['created_at'] ?? ''),
263            'last_used_at' => isset($row['last_used_at']) ? (string) $row['last_used_at'] : null,
264            'is_active'    => (int) ($row['is_active'] ?? 1),
265            'is_default'   => (int) ($row['is_default'] ?? 0),
266        ];
267    }
268
269    /** {@inheritdoc} */
270    public function verifyUserMfa(int $userId, string $code): bool
271    {
272        $clean = trim($code);
273        if ($clean === '') {
274            return false;
275        }
276
277        // 1. Try TOTP against active devices
278        if ($this->verifyAgainstTotpDevices($userId, $clean)) {
279            return true;
280        }
281
282        // 2. Try single-use recovery codes
283        return $this->verifyAgainstRecoveryCode($userId, $clean);
284    }
285
286    /**
287     * Checks if user already has unused recovery codes.
288     */
289    private function hasExistingRecoveryCodes(int $userId): bool
290    {
291        $stmt = $this->pdo->prepare(
292            "SELECT COUNT(*) FROM `{$this->tableCodes}` WHERE `user_id` = :uid AND `used_at` IS NULL"
293        );
294        $stmt->execute([':uid' => $userId]);
295        return ((int) $stmt->fetchColumn()) > 0;
296    }
297
298    /**
299     * Generates fresh batch of 8 recovery codes and persists SHA-256 hashes.
300     *
301     * @return array<int, string> Plaintext recovery codes for one-time display.
302     */
303    private function generateAndStoreRecoveryCodes(int $userId): array
304    {
305        $plainCodes = $this->totpService->generateRecoveryCodes(8);
306        $insert = $this->pdo->prepare(
307            "INSERT INTO `{$this->tableCodes}` (`user_id`, `code_hash`) VALUES (:uid, :chash)"
308        );
309
310        foreach ($plainCodes as $code) {
311            $hash = hash('sha256', strtoupper(str_replace('-', '', $code)));
312            $insert->execute([':uid' => $userId, ':chash' => $hash]);
313        }
314
315        return $plainCodes;
316    }
317
318    /**
319     * Verifies TOTP against user's registered active devices.
320     */
321    private function verifyAgainstTotpDevices(int $userId, string $code): bool
322    {
323        $stmt = $this->pdo->prepare(
324            'SELECT id, secret_encrypted, last_used_step, algorithm, digits, period ' .
325            "FROM `{$this->tableTokens}` WHERE `user_id` = :uid AND `is_active` = 1 " .
326            'ORDER BY is_default DESC, id DESC'
327        );
328        $stmt->execute([':uid' => $userId]);
329        $tokens = $stmt->fetchAll(PDO::FETCH_ASSOC);
330
331        foreach ($tokens as $token) {
332            $secret = $this->totpService->decryptSecret((string) $token['secret_encrypted']);
333            $lastStep = isset($token['last_used_step']) ? (int) $token['last_used_step'] : null;
334            $period = (int) ($token['period'] ?? 30);
335            $algorithm = (string) ($token['algorithm'] ?? 'sha1');
336
337            $matchedStep = $this->totpService->verifyCode(
338                code: $code,
339                secret: $secret,
340                lastUsedStep: $lastStep,
341                window: 1,
342                period: $period,
343                algorithm: $algorithm
344            );
345
346            if ($matchedStep !== null) {
347                $upd = $this->pdo->prepare(
348                    "UPDATE `{$this->tableTokens}` SET `last_used_step` = :step, `last_used_at` = NOW() " .
349                    'WHERE `id` = :id'
350                );
351                $upd->execute([':step' => $matchedStep, ':id' => $token['id']]);
352                return true;
353            }
354        }
355
356        return false;
357    }
358
359    /**
360     * Verifies single-use emergency recovery code.
361     */
362    private function verifyAgainstRecoveryCode(int $userId, string $code): bool
363    {
364        $normalized = strtoupper(str_replace(['-', ' '], '', $code));
365        $hash = hash('sha256', $normalized);
366
367        $stmt = $this->pdo->prepare(
368            "SELECT id FROM `{$this->tableCodes}" .
369            'WHERE `user_id` = :uid AND `code_hash` = :hash AND `used_at` IS NULL LIMIT 1'
370        );
371        $stmt->execute([':uid' => $userId, ':hash' => $hash]);
372        $recordId = $stmt->fetchColumn();
373
374        if ($recordId === false) {
375            return false;
376        }
377
378        $upd = $this->pdo->prepare(
379            "UPDATE `{$this->tableCodes}` SET `used_at` = NOW() WHERE `id` = :id"
380        );
381        $upd->execute([':id' => $recordId]);
382
383        return true;
384    }
385
386    /**
387     * Synchronizes is_mfa_enabled flag in user records based on active MFA devices.
388     *
389     * @param int $userId User ID.
390     */
391    private function syncUserMfaStatus(int $userId): void
392    {
393        $hasActive = $this->hasActiveMfa($userId);
394        $stmt = $this->pdo->prepare(
395            "UPDATE `{$this->tableUsers}` SET `is_mfa_enabled` = :enabled, `updated_at` = NOW(6) WHERE `id` = :uid"
396        );
397        $stmt->execute([
398            ':enabled' => $hasActive ? 1 : 0,
399            ':uid'     => $userId,
400        ]);
401    }
402}
403