Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoginAccessService
0.00% covered (danger)
0.00%
0 / 152
0.00% covered (danger)
0.00%
0 / 16
1560
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enableLogin
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 disableLogin
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 setCredentials
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 changeUsername
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 sendInvitation
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 resendInvitation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 revokeInvitation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 generatePasswordResetToken
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 resetPassword
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getLoginStatus
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 disableMfa
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 findUser
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 isUsernameTaken
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 validateUsername
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 validatePassword
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace BuyerKiosk\TeamMember\Services;
4
5use PDO;
6use InvalidArgumentException;
7
8/**
9 * LoginAccessService - Manages login access for team members
10 *
11 * Handles all login-related operations including:
12 * - Enabling/disabling login capability
13 * - Setting up credentials (username/password)
14 * - Sending and managing invitations
15 * - Password resets
16 * - MFA management
17 *
18 * Works with the unified users table in the central database (kiosk_users).
19 *
20 * @see docs/specs/014-manage-employees-unified/solution-design.md
21 *
22 * @package BuyerKiosk\TeamMember\Services
23 */
24class LoginAccessService
25{
26    /**
27     * Minimum password length
28     */
29    private const MIN_PASSWORD_LENGTH = 8;
30
31    /**
32     * Minimum username length
33     */
34    private const MIN_USERNAME_LENGTH = 3;
35
36    /**
37     * Maximum username length
38     */
39    private const MAX_USERNAME_LENGTH = 50;
40
41    /**
42     * Invitation token expiration (24 hours)
43     */
44    private const INVITATION_EXPIRES_HOURS = 24;
45
46    /**
47     * Password reset token expiration (1 hour)
48     */
49    private const RESET_TOKEN_EXPIRES_HOURS = 1;
50
51    /**
52     * @var PDO Central database connection (kiosk_users)
53     */
54    private PDO $db;
55
56    /**
57     * Constructor
58     *
59     * @param PDO $centralDb Central database connection (kiosk_users)
60     */
61    public function __construct(PDO $centralDb)
62    {
63        $this->db = $centralDb;
64    }
65
66    // =========================================================================
67    // Login Enable/Disable
68    // =========================================================================
69
70    /**
71     * Enable login access for a user
72     *
73     * @param int $userId User ID
74     * @return bool Success status
75     */
76    public function enableLogin(int $userId): bool
77    {
78        $user = $this->findUser($userId);
79        if (!$user) {
80            return false;
81        }
82
83        $sql = "UPDATE users SET canLogin = 1, updatedAt = NOW() WHERE id = :id";
84        $stmt = $this->db->prepare($sql);
85        return $stmt->execute(['id' => $userId]);
86    }
87
88    /**
89     * Disable login access for a user
90     *
91     * @param int $userId User ID
92     * @return bool Success status
93     */
94    public function disableLogin(int $userId): bool
95    {
96        $user = $this->findUser($userId);
97        if (!$user) {
98            return false;
99        }
100
101        $sql = "UPDATE users SET canLogin = 0, updatedAt = NOW() WHERE id = :id";
102        $stmt = $this->db->prepare($sql);
103        return $stmt->execute(['id' => $userId]);
104    }
105
106    // =========================================================================
107    // Credentials Management
108    // =========================================================================
109
110    /**
111     * Set username and password for a user
112     *
113     * @param int $userId User ID
114     * @param string $username Username
115     * @param string $password Plain text password
116     * @return bool Success status
117     * @throws InvalidArgumentException If validation fails
118     */
119    public function setCredentials(int $userId, string $username, string $password): bool
120    {
121        $user = $this->findUser($userId);
122        if (!$user) {
123            return false;
124        }
125
126        // Validate username format
127        $this->validateUsername($username);
128
129        // Check username availability (excluding current user)
130        if ($this->isUsernameTaken($username, $userId)) {
131            throw new InvalidArgumentException('Username already taken');
132        }
133
134        // Validate password
135        $this->validatePassword($password);
136
137        // Hash password with Argon2id
138        $passwordHash = password_hash($password, PASSWORD_ARGON2ID, [
139            'memory_cost' => 65536,
140            'time_cost' => 3,
141            'threads' => 4,
142        ]);
143
144        $sql = "UPDATE users
145                SET username = :username,
146                    password = :password,
147                    canLogin = 1,
148                    active = 1,
149                    updatedAt = NOW()
150                WHERE id = :id";
151
152        $stmt = $this->db->prepare($sql);
153        return $stmt->execute([
154            'username' => $username,
155            'password' => $passwordHash,
156            'id' => $userId,
157        ]);
158    }
159
160    /**
161     * Change username for a user
162     *
163     * @param int $userId User ID
164     * @param string $username New username
165     * @return bool Success status
166     * @throws InvalidArgumentException If validation fails
167     */
168    public function changeUsername(int $userId, string $username): bool
169    {
170        $user = $this->findUser($userId);
171        if (!$user) {
172            return false;
173        }
174
175        // Validate username format
176        $this->validateUsername($username);
177
178        // Check username availability (excluding current user)
179        if ($this->isUsernameTaken($username, $userId)) {
180            throw new InvalidArgumentException('Username already taken');
181        }
182
183        $sql = "UPDATE users SET username = :username, updatedAt = NOW() WHERE id = :id";
184        $stmt = $this->db->prepare($sql);
185        return $stmt->execute(['username' => $username, 'id' => $userId]);
186    }
187
188    // =========================================================================
189    // Invitation Management
190    // =========================================================================
191
192    /**
193     * Send an invitation to set up login
194     *
195     * @param int $userId User ID
196     * @return array{token: string, expiresAt: string}|null Token info or null if not found
197     * @throws InvalidArgumentException If user has no email
198     */
199    public function sendInvitation(int $userId): ?array
200    {
201        $user = $this->findUser($userId);
202        if (!$user) {
203            return null;
204        }
205
206        // Require email for invitation
207        if (empty($user['email'])) {
208            throw new InvalidArgumentException('User must have an email address to receive invitation');
209        }
210
211        // Generate secure token
212        $token = bin2hex(random_bytes(32));
213        $expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::INVITATION_EXPIRES_HOURS . ' hours'));
214
215        $sql = "UPDATE users
216                SET activationToken = :token,
217                    activationTokenExpiresAt = :expiresAt,
218                    updatedAt = NOW()
219                WHERE id = :id";
220
221        $stmt = $this->db->prepare($sql);
222        $stmt->execute([
223            'token' => $token,
224            'expiresAt' => $expiresAt,
225            'id' => $userId,
226        ]);
227
228        return [
229            'token' => $token,
230            'expiresAt' => $expiresAt,
231        ];
232    }
233
234    /**
235     * Resend invitation with new token
236     *
237     * @param int $userId User ID
238     * @return array{token: string, expiresAt: string}|null Token info or null
239     */
240    public function resendInvitation(int $userId): ?array
241    {
242        return $this->sendInvitation($userId);
243    }
244
245    /**
246     * Revoke a pending invitation
247     *
248     * @param int $userId User ID
249     * @return bool Success status
250     */
251    public function revokeInvitation(int $userId): bool
252    {
253        $user = $this->findUser($userId);
254        if (!$user) {
255            return false;
256        }
257
258        $sql = "UPDATE users
259                SET activationToken = NULL,
260                    activationTokenExpiresAt = NULL,
261                    updatedAt = NOW()
262                WHERE id = :id";
263
264        $stmt = $this->db->prepare($sql);
265        return $stmt->execute(['id' => $userId]);
266    }
267
268    // =========================================================================
269    // Password Reset
270    // =========================================================================
271
272    /**
273     * Generate a password reset token
274     *
275     * @param int $userId User ID
276     * @return array{token: string, expiresAt: string}|null Token info or null
277     * @throws InvalidArgumentException If user doesn't have login access
278     */
279    public function generatePasswordResetToken(int $userId): ?array
280    {
281        $user = $this->findUser($userId);
282        if (!$user) {
283            return null;
284        }
285
286        // Require login to be enabled for reset
287        if (!$user['canLogin']) {
288            throw new InvalidArgumentException('User does not have login access');
289        }
290
291        // Generate secure token
292        $token = bin2hex(random_bytes(32));
293        $expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::RESET_TOKEN_EXPIRES_HOURS . ' hour'));
294
295        $sql = "UPDATE users
296                SET passwordResetToken = :token,
297                    passwordResetExpiresAt = :expiresAt,
298                    updatedAt = NOW()
299                WHERE id = :id";
300
301        $stmt = $this->db->prepare($sql);
302        $stmt->execute([
303            'token' => $token,
304            'expiresAt' => $expiresAt,
305            'id' => $userId,
306        ]);
307
308        return [
309            'token' => $token,
310            'expiresAt' => $expiresAt,
311        ];
312    }
313
314    /**
315     * Reset user's password (admin action)
316     *
317     * @param int $userId User ID
318     * @param string $newPassword New password
319     * @return bool Success status
320     * @throws InvalidArgumentException If validation fails
321     */
322    public function resetPassword(int $userId, string $newPassword): bool
323    {
324        $user = $this->findUser($userId);
325        if (!$user) {
326            return false;
327        }
328
329        // Validate password
330        $this->validatePassword($newPassword);
331
332        // Hash password with Argon2id
333        $passwordHash = password_hash($newPassword, PASSWORD_ARGON2ID, [
334            'memory_cost' => 65536,
335            'time_cost' => 3,
336            'threads' => 4,
337        ]);
338
339        $sql = "UPDATE users
340                SET password = :password,
341                    passwordResetToken = NULL,
342                    passwordResetExpiresAt = NULL,
343                    updatedAt = NOW()
344                WHERE id = :id";
345
346        $stmt = $this->db->prepare($sql);
347        return $stmt->execute([
348            'password' => $passwordHash,
349            'id' => $userId,
350        ]);
351    }
352
353    // =========================================================================
354    // Status Queries
355    // =========================================================================
356
357    /**
358     * Get comprehensive login status for a user
359     *
360     * @param int $userId User ID
361     * @return array|null Status info or null if not found
362     */
363    public function getLoginStatus(int $userId): ?array
364    {
365        $user = $this->findUser($userId);
366        if (!$user) {
367            return null;
368        }
369
370        // Check pending invitation
371        $hasPendingInvitation = false;
372        if (!empty($user['activationToken']) && !empty($user['activationTokenExpiresAt'])) {
373            $hasPendingInvitation = strtotime($user['activationTokenExpiresAt']) > time();
374        }
375
376        return [
377            'canLogin' => (bool) $user['canLogin'],
378            'hasUsername' => !empty($user['username']),
379            'hasPassword' => !empty($user['password']),
380            'isEnabled' => (bool) $user['enabled'],
381            'isActive' => (bool) $user['active'],
382            'mfaEnabled' => (bool) $user['mfaEnabled'],
383            'hasPendingInvitation' => $hasPendingInvitation,
384            'invitationExpiresAt' => $hasPendingInvitation ? $user['activationTokenExpiresAt'] : null,
385        ];
386    }
387
388    // =========================================================================
389    // MFA Management
390    // =========================================================================
391
392    /**
393     * Disable MFA for a user (admin action)
394     *
395     * @param int $userId User ID
396     * @return bool Success status
397     */
398    public function disableMfa(int $userId): bool
399    {
400        $user = $this->findUser($userId);
401        if (!$user) {
402            return false;
403        }
404
405        // Check if MFA is actually enabled
406        if (!$user['mfaEnabled']) {
407            return false;
408        }
409
410        $sql = "UPDATE users
411                SET mfaEnabled = 0,
412                    mfaSecret = NULL,
413                    mfaBackupCodes = NULL,
414                    mfaVerifiedAt = NULL,
415                    updatedAt = NOW()
416                WHERE id = :id";
417
418        $stmt = $this->db->prepare($sql);
419        return $stmt->execute(['id' => $userId]);
420    }
421
422    // =========================================================================
423    // Private Helper Methods
424    // =========================================================================
425
426    /**
427     * Find user by ID
428     *
429     * @param int $userId User ID
430     * @return array|null User row or null
431     */
432    private function findUser(int $userId): ?array
433    {
434        $sql = "SELECT id, username, email, password, canLogin, enabled, active,
435                       activationToken, activationTokenExpiresAt, mfaEnabled
436                FROM users WHERE id = :id LIMIT 1";
437
438        $stmt = $this->db->prepare($sql);
439        $stmt->execute(['id' => $userId]);
440        $row = $stmt->fetch(PDO::FETCH_ASSOC);
441
442        return $row ?: null;
443    }
444
445    /**
446     * Check if username is taken
447     *
448     * @param string $username Username to check
449     * @param int $excludeUserId User ID to exclude from check
450     * @return bool True if taken
451     */
452    private function isUsernameTaken(string $username, int $excludeUserId): bool
453    {
454        $sql = "SELECT id FROM users WHERE username = :username AND id != :excludeId LIMIT 1";
455        $stmt = $this->db->prepare($sql);
456        $stmt->execute(['username' => $username, 'excludeId' => $excludeUserId]);
457
458        return (bool) $stmt->fetch(PDO::FETCH_ASSOC);
459    }
460
461    /**
462     * Validate username format
463     *
464     * @param string $username Username to validate
465     * @throws InvalidArgumentException If invalid
466     */
467    private function validateUsername(string $username): void
468    {
469        $length = strlen($username);
470
471        if ($length < self::MIN_USERNAME_LENGTH || $length > self::MAX_USERNAME_LENGTH) {
472            throw new InvalidArgumentException(
473                'Username must be ' . self::MIN_USERNAME_LENGTH . '-' . self::MAX_USERNAME_LENGTH . ' characters'
474            );
475        }
476
477        // Allow alphanumeric, underscores, and hyphens
478        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $username)) {
479            throw new InvalidArgumentException(
480                'Username can only contain letters, numbers, underscores, and hyphens'
481            );
482        }
483    }
484
485    /**
486     * Validate password strength
487     *
488     * @param string $password Password to validate
489     * @throws InvalidArgumentException If invalid
490     */
491    private function validatePassword(string $password): void
492    {
493        if (strlen($password) < self::MIN_PASSWORD_LENGTH) {
494            throw new InvalidArgumentException(
495                'Password must be at least ' . self::MIN_PASSWORD_LENGTH . ' characters'
496            );
497        }
498    }
499}