Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.22% covered (danger)
1.22%
1 / 82
4.00% covered (danger)
4.00%
1 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuditLogger
1.22% covered (danger)
1.22%
1 / 82
4.00% covered (danger)
4.00%
1 / 25
1285.16
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
 log
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 logLogin
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 logLoginFailed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 logLogout
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logPasswordChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logPasswordResetRequest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 logPasswordResetComplete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logMfaEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logMfaDisabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logMfaSuccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logMfaFailed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logTokenIssued
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logTokenRefreshed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logTokenRevoked
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 logAccountLocked
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 logAccountUnlocked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logRememberMeIssued
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logRememberMeUsed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logRememberMeRevoked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logPasswordHashUpgraded
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getRecentEvents
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getClientIp
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getUserAgent
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 maskEmail
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Audit Logger Service
4 *
5 * Logs authentication and security events to the authAuditLog table.
6 * Provides structured logging for login attempts, password changes, MFA events, etc.
7 *
8 * @package BuyerKiosk\Auth\Services
9 */
10
11namespace BuyerKiosk\Auth\Services;
12
13class AuditLogger
14{
15    /**
16     * @var \PDO Database connection
17     */
18    private $db;
19
20    /**
21     * Valid event types (must match ENUM in authAuditLog table)
22     */
23    private const EVENT_TYPES = [
24        'login_success',
25        'login_failed',
26        'logout',
27        'password_change',
28        'password_reset_request',
29        'password_reset_complete',
30        'mfa_enabled',
31        'mfa_disabled',
32        'mfa_success',
33        'mfa_failed',
34        'token_issued',
35        'token_refreshed',
36        'token_revoked',
37        'account_locked',
38        'account_unlocked',
39        'api_key_created',
40        'api_key_revoked',
41        'remember_me_issued',
42        'remember_me_used',
43        'remember_me_revoked',
44        'password_hash_upgraded'
45    ];
46
47    /**
48     * Constructor
49     *
50     * @param \PDO $db Database connection
51     */
52    public function __construct(\PDO $db)
53    {
54        $this->db = $db;
55    }
56
57    /**
58     * Log an authentication event
59     *
60     * @param string $eventType Event type (must be valid enum value)
61     * @param int|null $userId User ID (null for anonymous events like failed login)
62     * @param array $details Additional event details (stored as JSON)
63     * @return bool True if logged successfully
64     */
65    public function log(string $eventType, ?int $userId = null, array $details = []): bool
66    {
67        if (!in_array($eventType, self::EVENT_TYPES)) {
68            error_log("AuditLogger: Invalid event type '$eventType'");
69            return false;
70        }
71
72        try {
73            $stmt = $this->db->prepare("
74                INSERT INTO authAuditLog
75                (userId, eventType, ipAddress, userAgent, details, createdAt)
76                VALUES (:userId, :eventType, :ipAddress, :userAgent, :details, NOW())
77            ");
78
79            $stmt->execute([
80                'userId' => $userId,
81                'eventType' => $eventType,
82                'ipAddress' => $this->getClientIp(),
83                'userAgent' => $this->getUserAgent(),
84                'details' => !empty($details) ? json_encode($details) : null
85            ]);
86
87            return true;
88        } catch (\PDOException $e) {
89            error_log("AuditLogger: Failed to log event '$eventType' - " . $e->getMessage());
90            return false;
91        }
92    }
93
94    /**
95     * Log a successful login
96     *
97     * @param int $userId User ID
98     * @param string|null $method Login method (password, mfa, remember_me, api_key)
99     * @return bool
100     */
101    public function logLogin(int $userId, ?string $method = 'password'): bool
102    {
103        return $this->log('login_success', $userId, [
104            'method' => $method,
105            'timestamp' => date('c')
106        ]);
107    }
108
109    /**
110     * Log a failed login attempt
111     *
112     * @param int|null $userId User ID if known
113     * @param string $reason Failure reason
114     * @param string|null $attemptedUsername Username that was attempted
115     * @return bool
116     */
117    public function logLoginFailed(?int $userId, string $reason, ?string $attemptedUsername = null): bool
118    {
119        $details = ['reason' => $reason];
120        if ($attemptedUsername) {
121            $details['attempted_username'] = $attemptedUsername;
122        }
123        return $this->log('login_failed', $userId, $details);
124    }
125
126    /**
127     * Log a logout event
128     *
129     * @param int $userId User ID
130     * @param string|null $method Logout method (manual, session_expired, forced)
131     * @return bool
132     */
133    public function logLogout(int $userId, ?string $method = 'manual'): bool
134    {
135        return $this->log('logout', $userId, ['method' => $method]);
136    }
137
138    /**
139     * Log a password change
140     *
141     * @param int $userId User ID
142     * @param bool $forced Whether the change was forced (expired, admin reset)
143     * @return bool
144     */
145    public function logPasswordChange(int $userId, bool $forced = false): bool
146    {
147        return $this->log('password_change', $userId, ['forced' => $forced]);
148    }
149
150    /**
151     * Log a password reset request
152     *
153     * @param int|null $userId User ID if email matched a user
154     * @param string $email Email address requested
155     * @return bool
156     */
157    public function logPasswordResetRequest(?int $userId, string $email): bool
158    {
159        return $this->log('password_reset_request', $userId, [
160            'email' => $this->maskEmail($email)
161        ]);
162    }
163
164    /**
165     * Log a completed password reset
166     *
167     * @param int $userId User ID
168     * @return bool
169     */
170    public function logPasswordResetComplete(int $userId): bool
171    {
172        return $this->log('password_reset_complete', $userId);
173    }
174
175    /**
176     * Log MFA enable event
177     *
178     * @param int $userId User ID
179     * @param string $method MFA method (totp, backup_codes)
180     * @return bool
181     */
182    public function logMfaEnabled(int $userId, string $method = 'totp'): bool
183    {
184        return $this->log('mfa_enabled', $userId, ['method' => $method]);
185    }
186
187    /**
188     * Log MFA disable event
189     *
190     * @param int $userId User ID
191     * @return bool
192     */
193    public function logMfaDisabled(int $userId): bool
194    {
195        return $this->log('mfa_disabled', $userId);
196    }
197
198    /**
199     * Log successful MFA verification
200     *
201     * @param int $userId User ID
202     * @param string $method Method used (totp, backup_code)
203     * @return bool
204     */
205    public function logMfaSuccess(int $userId, string $method = 'totp'): bool
206    {
207        return $this->log('mfa_success', $userId, ['method' => $method]);
208    }
209
210    /**
211     * Log failed MFA verification
212     *
213     * @param int $userId User ID
214     * @param string $reason Failure reason
215     * @return bool
216     */
217    public function logMfaFailed(int $userId, string $reason = 'invalid_code'): bool
218    {
219        return $this->log('mfa_failed', $userId, ['reason' => $reason]);
220    }
221
222    /**
223     * Log token issued event
224     *
225     * @param int $userId User ID
226     * @param string $tokenType Token type (access, refresh)
227     * @return bool
228     */
229    public function logTokenIssued(int $userId, string $tokenType = 'access'): bool
230    {
231        return $this->log('token_issued', $userId, ['token_type' => $tokenType]);
232    }
233
234    /**
235     * Log token refresh event
236     *
237     * @param int $userId User ID
238     * @return bool
239     */
240    public function logTokenRefreshed(int $userId): bool
241    {
242        return $this->log('token_refreshed', $userId);
243    }
244
245    /**
246     * Log token revocation
247     *
248     * @param int $userId User ID
249     * @param string $reason Revocation reason
250     * @param int $tokenCount Number of tokens revoked
251     * @return bool
252     */
253    public function logTokenRevoked(int $userId, string $reason = 'manual', int $tokenCount = 1): bool
254    {
255        return $this->log('token_revoked', $userId, [
256            'reason' => $reason,
257            'count' => $tokenCount
258        ]);
259    }
260
261    /**
262     * Log account locked event
263     *
264     * @param int $userId User ID
265     * @param int $failedAttempts Number of failed attempts
266     * @param string $lockedUntil Lock expiration timestamp
267     * @return bool
268     */
269    public function logAccountLocked(int $userId, int $failedAttempts, string $lockedUntil): bool
270    {
271        return $this->log('account_locked', $userId, [
272            'failed_attempts' => $failedAttempts,
273            'locked_until' => $lockedUntil
274        ]);
275    }
276
277    /**
278     * Log account unlocked event
279     *
280     * @param int $userId User ID
281     * @param string $method How unlocked (expired, admin, successful_login)
282     * @return bool
283     */
284    public function logAccountUnlocked(int $userId, string $method = 'expired'): bool
285    {
286        return $this->log('account_unlocked', $userId, ['method' => $method]);
287    }
288
289    /**
290     * Log remember-me token issued
291     *
292     * @param int $userId User ID
293     * @return bool
294     */
295    public function logRememberMeIssued(int $userId): bool
296    {
297        return $this->log('remember_me_issued', $userId);
298    }
299
300    /**
301     * Log remember-me token used
302     *
303     * @param int $userId User ID
304     * @return bool
305     */
306    public function logRememberMeUsed(int $userId): bool
307    {
308        return $this->log('remember_me_used', $userId);
309    }
310
311    /**
312     * Log remember-me tokens revoked
313     *
314     * @param int $userId User ID
315     * @param string $reason Revocation reason (password_change, manual, logout)
316     * @return bool
317     */
318    public function logRememberMeRevoked(int $userId, string $reason = 'manual'): bool
319    {
320        return $this->log('remember_me_revoked', $userId, ['reason' => $reason]);
321    }
322
323    /**
324     * Log password hash upgrade (legacy hash upgraded to modern)
325     *
326     * @param int $userId User ID
327     * @param string $oldType Old hash type (sha1, bcrypt)
328     * @param string $newType New hash type (argon2id)
329     * @return bool
330     */
331    public function logPasswordHashUpgraded(int $userId, string $oldType, string $newType = 'argon2id'): bool
332    {
333        return $this->log('password_hash_upgraded', $userId, [
334            'old_type' => $oldType,
335            'new_type' => $newType
336        ]);
337    }
338
339    /**
340     * Get recent events for a user (for account activity display)
341     *
342     * @param int $userId User ID
343     * @param int $limit Number of events to return
344     * @return array List of events
345     */
346    public function getRecentEvents(int $userId, int $limit = 10): array
347    {
348        $stmt = $this->db->prepare("
349            SELECT eventType, ipAddress, userAgent, details, createdAt
350            FROM authAuditLog
351            WHERE userId = :userId
352            ORDER BY createdAt DESC
353            LIMIT :limit
354        ");
355        $stmt->bindValue('userId', $userId, \PDO::PARAM_INT);
356        $stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
357        $stmt->execute();
358
359        $events = $stmt->fetchAll(\PDO::FETCH_ASSOC);
360
361        // Parse JSON details
362        foreach ($events as &$event) {
363            if ($event['details']) {
364                $event['details'] = json_decode($event['details'], true);
365            }
366        }
367
368        return $events;
369    }
370
371    /**
372     * Get the client IP address
373     *
374     * @return string|null IP address
375     */
376    private function getClientIp(): ?string
377    {
378        // Check for forwarded IP (behind load balancer/proxy)
379        if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
380            $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
381            return trim($ips[0]);
382        }
383
384        if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
385            return $_SERVER['HTTP_X_REAL_IP'];
386        }
387
388        return $_SERVER['REMOTE_ADDR'] ?? null;
389    }
390
391    /**
392     * Get the user agent string
393     *
394     * @return string|null User agent
395     */
396    private function getUserAgent(): ?string
397    {
398        $ua = $_SERVER['HTTP_USER_AGENT'] ?? null;
399
400        // Truncate if too long
401        if ($ua && strlen($ua) > 500) {
402            $ua = substr($ua, 0, 500);
403        }
404
405        return $ua;
406    }
407
408    /**
409     * Mask an email address for privacy in logs
410     *
411     * @param string $email Email address
412     * @return string Masked email (e.g., j***@e***.com)
413     */
414    private function maskEmail(string $email): string
415    {
416        $parts = explode('@', $email);
417        if (count($parts) !== 2) {
418            return '***';
419        }
420
421        $local = $parts[0];
422        $domain = $parts[1];
423
424        $maskedLocal = substr($local, 0, 1) . '***';
425        $domainParts = explode('.', $domain);
426        $maskedDomain = substr($domainParts[0], 0, 1) . '***.' . end($domainParts);
427
428        return $maskedLocal . '@' . $maskedDomain;
429    }
430}