Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.34% covered (success)
97.34%
183 / 188
87.50% covered (warning)
87.50%
14 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
MfaService
97.34% covered (success)
97.34%
183 / 188
87.50% covered (warning)
87.50%
14 / 16
53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateSecret
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getQrCodeUri
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateQrCode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 verifyCode
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 generateBackupCodes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 verifyBackupCode
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
8.03
 getRemainingBackupCodeCount
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 beginSetup
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
4
 completeSetup
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 disable
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 forceDisable
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 regenerateBackupCodes
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 verifyAnyCode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 isEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getStatus
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Multi-Factor Authentication Service
4 *
5 * Implements TOTP (Time-based One-Time Password) authentication using the OTPHP library.
6 * Provides QR code generation, code verification, and backup code management.
7 *
8 * @package BuyerKiosk\Auth\Services
9 */
10
11namespace BuyerKiosk\Auth\Services;
12
13use OTPHP\TOTP;
14use ParagonIE\ConstantTime\Base32;
15
16class MfaService
17{
18    /**
19     * @var \PDO Database connection
20     */
21    private $db;
22
23    /**
24     * @var AuditLogger Audit logging service
25     */
26    private $auditLogger;
27
28    /**
29     * Application name shown in authenticator apps
30     */
31    private const APP_NAME = 'BuyerKiosk';
32
33    /**
34     * Number of backup codes to generate
35     */
36    private const BACKUP_CODE_COUNT = 10;
37
38    /**
39     * Backup code length (digits)
40     */
41    private const BACKUP_CODE_LENGTH = 8;
42
43    /**
44     * Time drift tolerance in periods (30 sec each)
45     * Allows 1 period before/after current time
46     */
47    private const TIME_DRIFT_TOLERANCE = 1;
48
49    /**
50     * Constructor
51     *
52     * @param \PDO $db Database connection
53     * @param AuditLogger|null $auditLogger Audit logger (auto-created if null)
54     */
55    public function __construct(\PDO $db, ?AuditLogger $auditLogger = null)
56    {
57        $this->db = $db;
58        $this->auditLogger = $auditLogger ?? new AuditLogger($db);
59    }
60
61    /**
62     * Generate a new TOTP secret for MFA setup
63     *
64     * @return string Base32-encoded secret
65     */
66    public function generateSecret(): string
67    {
68        // Generate 20 random bytes (160 bits) for the secret
69        $secret = random_bytes(20);
70        return Base32::encodeUpper($secret);
71    }
72
73    /**
74     * Generate a QR code URI for authenticator apps
75     *
76     * This returns the otpauth:// URI that can be embedded in a QR code.
77     * Compatible with Google Authenticator, Authy, 1Password, etc.
78     *
79     * @param string $secret Base32-encoded secret
80     * @param string $username User's email or username for display
81     * @return string otpauth:// URI for QR code
82     */
83    public function getQrCodeUri(string $secret, string $username): string
84    {
85        $totp = TOTP::create($secret);
86        $totp->setLabel($username);
87        $totp->setIssuer(self::APP_NAME);
88        $totp->setParameter('digits', 6);
89        $totp->setParameter('period', 30);
90
91        return $totp->getProvisioningUri();
92    }
93
94    /**
95     * Generate a QR code as a data URI (base64-encoded SVG)
96     *
97     * @param string $secret Base32-encoded secret
98     * @param string $username User's email or username
99     * @return string Data URI containing SVG QR code
100     */
101    public function generateQrCode(string $secret, string $username): string
102    {
103        $uri = $this->getQrCodeUri($secret, $username);
104
105        // Generate QR code using endroid/qr-code if available
106        // Fall back to Google Charts API URL for client-side rendering
107        // For now, return the URI for client-side QR generation
108        return $uri;
109    }
110
111    /**
112     * Verify a TOTP code against the user's secret
113     *
114     * Allows for time drift tolerance (1 period = 30 seconds before/after)
115     *
116     * @param string $secret Base32-encoded secret
117     * @param string $code 6-digit code to verify
118     * @return bool True if code is valid
119     */
120    public function verifyCode(string $secret, string $code): bool
121    {
122        // Normalize code - remove spaces and ensure 6 digits
123        $code = preg_replace('/\s+/', '', $code);
124        if (!preg_match('/^\d{6}$/', $code)) {
125            return false;
126        }
127
128        try {
129            $totp = TOTP::create($secret);
130            $totp->setParameter('digits', 6);
131            $totp->setParameter('period', 30);
132
133            // Verify with time drift tolerance
134            return $totp->verify($code, null, self::TIME_DRIFT_TOLERANCE);
135        } catch (\Exception $e) {
136            error_log("MfaService: TOTP verification error - " . $e->getMessage());
137            return false;
138        }
139    }
140
141    /**
142     * Generate backup codes for recovery
143     *
144     * @param int $count Number of codes to generate
145     * @return array ['codes' => plaintext codes for display, 'hashes' => hashed codes for storage]
146     */
147    public function generateBackupCodes(int $count = self::BACKUP_CODE_COUNT): array
148    {
149        $codes = [];
150        $hashes = [];
151
152        for ($i = 0; $i < $count; $i++) {
153            // Generate random 8-digit code
154            $code = '';
155            for ($j = 0; $j < self::BACKUP_CODE_LENGTH; $j++) {
156                $code .= random_int(0, 9);
157            }
158
159            // Format as XXXX-XXXX for readability
160            $formattedCode = substr($code, 0, 4) . '-' . substr($code, 4, 4);
161            $codes[] = $formattedCode;
162
163            // Store hash of the formatted code
164            $hashes[] = hash('sha256', $formattedCode);
165        }
166
167        return [
168            'codes' => $codes,
169            'hashes' => $hashes
170        ];
171    }
172
173    /**
174     * Verify a backup code and consume it if valid
175     *
176     * @param int $userId User ID
177     * @param string $code Backup code to verify
178     * @return bool True if code is valid (and now consumed)
179     */
180    public function verifyBackupCode(int $userId, string $code): bool
181    {
182        // Normalize code - remove spaces, ensure format
183        $code = strtoupper(trim($code));
184        if (!preg_match('/^\d{4}-?\d{4}$/', $code)) {
185            return false;
186        }
187
188        // Ensure hyphen format
189        if (strlen($code) === 8) {
190            $code = substr($code, 0, 4) . '-' . substr($code, 4, 4);
191        }
192
193        $stmt = $this->db->prepare("SELECT mfaBackupCodes FROM users WHERE id = :id");
194        $stmt->execute(['id' => $userId]);
195        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
196
197        if (!$user || empty($user['mfaBackupCodes'])) {
198            return false;
199        }
200
201        $backupCodes = json_decode($user['mfaBackupCodes'], true);
202        if (!is_array($backupCodes)) {
203            return false;
204        }
205
206        // Find and remove the matching code
207        $hashedInput = hash('sha256', $code);
208
209        foreach ($backupCodes as $key => $storedHash) {
210            if (hash_equals($storedHash, $hashedInput)) {
211                // Remove used code
212                unset($backupCodes[$key]);
213
214                // Update database
215                $updateStmt = $this->db->prepare("
216                    UPDATE users SET mfaBackupCodes = :codes WHERE id = :id
217                ");
218                $updateStmt->execute([
219                    'codes' => json_encode(array_values($backupCodes)),
220                    'id' => $userId
221                ]);
222
223                $this->auditLogger->logMfaSuccess($userId, 'backup_code');
224                return true;
225            }
226        }
227
228        return false;
229    }
230
231    /**
232     * Get the number of remaining backup codes for a user
233     *
234     * @param int $userId User ID
235     * @return int Number of remaining codes
236     */
237    public function getRemainingBackupCodeCount(int $userId): int
238    {
239        $stmt = $this->db->prepare("SELECT mfaBackupCodes FROM users WHERE id = :id");
240        $stmt->execute(['id' => $userId]);
241        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
242
243        if (!$user || empty($user['mfaBackupCodes'])) {
244            return 0;
245        }
246
247        $backupCodes = json_decode($user['mfaBackupCodes'], true);
248        return is_array($backupCodes) ? count($backupCodes) : 0;
249    }
250
251    /**
252     * Begin MFA setup process for a user
253     *
254     * Generates secret and backup codes, but does NOT enable MFA yet.
255     * User must verify a TOTP code to complete setup.
256     *
257     * @param int $userId User ID
258     * @return array Setup data including secret, QR URI, and backup codes
259     */
260    public function beginSetup(int $userId): array
261    {
262        $stmt = $this->db->prepare("
263            SELECT username, email, mfaEnabled FROM users WHERE id = :id
264        ");
265        $stmt->execute(['id' => $userId]);
266        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
267
268        if (!$user) {
269            throw new \InvalidArgumentException("User not found: $userId");
270        }
271
272        if ($user['mfaEnabled']) {
273            throw new \RuntimeException("MFA is already enabled for this user");
274        }
275
276        // Generate new secret and backup codes
277        $secret = $this->generateSecret();
278        $backupCodes = $this->generateBackupCodes();
279        $label = $user['email'] ?: $user['username'];
280        $qrUri = $this->getQrCodeUri($secret, $label);
281
282        // Store secret temporarily (not enabled yet)
283        // We store it so we can verify the first code before enabling
284        $updateStmt = $this->db->prepare("
285            UPDATE users SET
286                mfaSecret = :secret,
287                mfaBackupCodes = :backupCodes,
288                mfaEnabled = 0
289            WHERE id = :id
290        ");
291        $updateStmt->execute([
292            'secret' => $secret,
293            'backupCodes' => json_encode($backupCodes['hashes']),
294            'id' => $userId
295        ]);
296
297        return [
298            'secret' => $secret,
299            'qrUri' => $qrUri,
300            'backupCodes' => $backupCodes['codes'], // Plaintext for user to save
301            'manualEntry' => [
302                'key' => $secret,
303                'type' => 'TOTP',
304                'algorithm' => 'SHA1',
305                'digits' => 6,
306                'period' => 30
307            ]
308        ];
309    }
310
311    /**
312     * Complete MFA setup by verifying the first TOTP code
313     *
314     * @param int $userId User ID
315     * @param string $code TOTP code to verify
316     * @return bool True if MFA was enabled successfully
317     */
318    public function completeSetup(int $userId, string $code): bool
319    {
320        $stmt = $this->db->prepare("
321            SELECT mfaSecret, mfaEnabled FROM users WHERE id = :id
322        ");
323        $stmt->execute(['id' => $userId]);
324        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
325
326        if (!$user) {
327            throw new \InvalidArgumentException("User not found: $userId");
328        }
329
330        if ($user['mfaEnabled']) {
331            throw new \RuntimeException("MFA is already enabled");
332        }
333
334        if (empty($user['mfaSecret'])) {
335            throw new \RuntimeException("MFA setup not started. Call beginSetup first.");
336        }
337
338        // Verify the code
339        if (!$this->verifyCode($user['mfaSecret'], $code)) {
340            return false;
341        }
342
343        // Enable MFA
344        $updateStmt = $this->db->prepare("
345            UPDATE users SET
346                mfaEnabled = 1,
347                mfaVerifiedAt = NOW()
348            WHERE id = :id
349        ");
350        $updateStmt->execute(['id' => $userId]);
351
352        $this->auditLogger->logMfaEnabled($userId, 'totp');
353
354        return true;
355    }
356
357    /**
358     * Disable MFA for a user (requires code verification)
359     *
360     * @param int $userId User ID
361     * @param string $code TOTP code or backup code
362     * @return bool True if MFA was disabled successfully
363     */
364    public function disable(int $userId, string $code): bool
365    {
366        $stmt = $this->db->prepare("
367            SELECT mfaSecret, mfaEnabled FROM users WHERE id = :id
368        ");
369        $stmt->execute(['id' => $userId]);
370        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
371
372        if (!$user) {
373            throw new \InvalidArgumentException("User not found: $userId");
374        }
375
376        if (!$user['mfaEnabled']) {
377            throw new \RuntimeException("MFA is not enabled");
378        }
379
380        // Verify the code (TOTP or backup code)
381        $isValid = $this->verifyCode($user['mfaSecret'], $code)
382            || $this->verifyBackupCode($userId, $code);
383
384        if (!$isValid) {
385            $this->auditLogger->logMfaFailed($userId, 'invalid_code_on_disable');
386            return false;
387        }
388
389        // Disable MFA
390        $updateStmt = $this->db->prepare("
391            UPDATE users SET
392                mfaEnabled = 0,
393                mfaSecret = NULL,
394                mfaBackupCodes = NULL,
395                mfaVerifiedAt = NULL
396            WHERE id = :id
397        ");
398        $updateStmt->execute(['id' => $userId]);
399
400        $this->auditLogger->logMfaDisabled($userId);
401
402        return true;
403    }
404
405    /**
406     * Force disable MFA (admin action, no code required)
407     *
408     * @param int $userId User ID
409     * @param int $adminUserId Admin user performing the action
410     * @return bool True if MFA was disabled
411     */
412    public function forceDisable(int $userId, int $adminUserId): bool
413    {
414        $updateStmt = $this->db->prepare("
415            UPDATE users SET
416                mfaEnabled = 0,
417                mfaSecret = NULL,
418                mfaBackupCodes = NULL,
419                mfaVerifiedAt = NULL
420            WHERE id = :id
421        ");
422        $updateStmt->execute(['id' => $userId]);
423
424        $this->auditLogger->log('mfa_disabled', $userId, [
425            'method' => 'admin_force',
426            'admin_user_id' => $adminUserId
427        ]);
428
429        return true;
430    }
431
432    /**
433     * Regenerate backup codes (requires TOTP verification)
434     *
435     * @param int $userId User ID
436     * @param string $code Current TOTP code to verify identity
437     * @return array|null New backup codes if successful, null if verification failed
438     */
439    public function regenerateBackupCodes(int $userId, string $code): ?array
440    {
441        $stmt = $this->db->prepare("
442            SELECT mfaSecret, mfaEnabled FROM users WHERE id = :id
443        ");
444        $stmt->execute(['id' => $userId]);
445        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
446
447        if (!$user || !$user['mfaEnabled']) {
448            throw new \RuntimeException("MFA is not enabled");
449        }
450
451        // Verify current TOTP code
452        if (!$this->verifyCode($user['mfaSecret'], $code)) {
453            return null;
454        }
455
456        // Generate new backup codes
457        $backupCodes = $this->generateBackupCodes();
458
459        $updateStmt = $this->db->prepare("
460            UPDATE users SET mfaBackupCodes = :codes WHERE id = :id
461        ");
462        $updateStmt->execute([
463            'codes' => json_encode($backupCodes['hashes']),
464            'id' => $userId
465        ]);
466
467        $this->auditLogger->log('mfa_success', $userId, [
468            'action' => 'backup_codes_regenerated'
469        ]);
470
471        return $backupCodes['codes'];
472    }
473
474    /**
475     * Verify any MFA code (TOTP or backup code)
476     *
477     * @param int $userId User ID
478     * @param string $code Code to verify
479     * @return bool True if code is valid
480     */
481    public function verifyAnyCode(int $userId, string $code): bool
482    {
483        $stmt = $this->db->prepare("
484            SELECT mfaSecret, mfaEnabled FROM users WHERE id = :id
485        ");
486        $stmt->execute(['id' => $userId]);
487        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
488
489        if (!$user || !$user['mfaEnabled'] || empty($user['mfaSecret'])) {
490            return false;
491        }
492
493        // Try TOTP first
494        if ($this->verifyCode($user['mfaSecret'], $code)) {
495            return true;
496        }
497
498        // Try backup code
499        if ($this->verifyBackupCode($userId, $code)) {
500            return true;
501        }
502
503        return false;
504    }
505
506    /**
507     * Check if MFA is enabled for a user
508     *
509     * @param int $userId User ID
510     * @return bool True if MFA is enabled
511     */
512    public function isEnabled(int $userId): bool
513    {
514        $stmt = $this->db->prepare("SELECT mfaEnabled FROM users WHERE id = :id");
515        $stmt->execute(['id' => $userId]);
516        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
517
518        return $user && (bool)$user['mfaEnabled'];
519    }
520
521    /**
522     * Get MFA status for a user
523     *
524     * @param int $userId User ID
525     * @return array MFA status info
526     */
527    public function getStatus(int $userId): array
528    {
529        $stmt = $this->db->prepare("
530            SELECT mfaEnabled, mfaVerifiedAt, mfaBackupCodes FROM users WHERE id = :id
531        ");
532        $stmt->execute(['id' => $userId]);
533        $user = $stmt->fetch(\PDO::FETCH_ASSOC);
534
535        if (!$user) {
536            throw new \InvalidArgumentException("User not found: $userId");
537        }
538
539        $backupCodes = $user['mfaBackupCodes'] ? json_decode($user['mfaBackupCodes'], true) : [];
540
541        return [
542            'enabled' => (bool)$user['mfaEnabled'],
543            'verifiedAt' => $user['mfaVerifiedAt'],
544            'backupCodesRemaining' => is_array($backupCodes) ? count($backupCodes) : 0
545        ];
546    }
547}