Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.31% covered (warning)
78.31%
65 / 83
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
RememberMeService
78.31% covered (warning)
78.31%
65 / 83
83.33% covered (warning)
83.33%
10 / 12
26.94
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
 createToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 validateAndRotate
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 validateToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 revokeAllForUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setCookie
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 clearCookie
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getTokenFromCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCookieName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSecureConnection
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 cleanupExpiredTokens
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Remember Me Service
4 *
5 * Secure remember-me token implementation with:
6 * - Hashed token storage (not plaintext)
7 * - Token rotation on each use (one-time tokens)
8 * - Secure cookie settings
9 * - Expiration handling
10 *
11 * Replaces the deprecated birke/rememberme library.
12 *
13 * MIGRATION NOTE: This service uses the existing uf_user_rememberme table
14 * but with hashed tokens instead of plaintext. Existing plaintext tokens
15 * will be invalidated (users will need to re-login with "remember me" checked).
16 *
17 * Table structure (existing):
18 * - user_id: INT
19 * - token: VARCHAR(255) - now stores SHA-256 hash instead of plaintext
20 * - persistent_token: VARCHAR(255) - no longer used, can be NULL
21 * - expires: DATETIME
22 *
23 * @package BuyerKiosk\Auth\Services
24 */
25
26namespace BuyerKiosk\Auth\Services;
27
28class RememberMeService
29{
30    /**
31     * @var \PDO Database connection
32     */
33    private $db;
34
35    /**
36     * @var string Cookie name
37     */
38    private const COOKIE_NAME = 'remember_me';
39
40    /**
41     * @var int Token expiry in days
42     */
43    private const TOKEN_EXPIRY_DAYS = 30;
44
45    /**
46     * @var string Table name for storing tokens
47     */
48    private const TABLE_NAME = 'uf_user_rememberme';
49
50    /**
51     * Constructor
52     *
53     * @param \PDO $db Database connection
54     */
55    public function __construct(\PDO $db)
56    {
57        $this->db = $db;
58    }
59
60    /**
61     * Create a new remember-me token for the user.
62     * Returns the plaintext token (to be sent to client via cookie).
63     * Only the hashed version is stored in the database.
64     *
65     * @param int $userId User ID
66     * @return string Plaintext token for cookie
67     */
68    public function createToken(int $userId): string
69    {
70        // Generate cryptographically secure token (64 hex chars = 32 bytes)
71        $plaintextToken = bin2hex(random_bytes(32));
72
73        // Hash before storing (if DB is compromised, tokens are useless)
74        $hashedToken = hash('sha256', $plaintextToken);
75
76        $expiresAt = date('Y-m-d H:i:s', strtotime('+' . self::TOKEN_EXPIRY_DAYS . ' days'));
77
78        // Delete any existing tokens for this user (one token per user policy)
79        // If you want to support multiple devices, remove this line
80        $this->revokeAllForUser($userId);
81
82        // Store hashed token (using existing table column names)
83        $stmt = $this->db->prepare("
84            INSERT INTO " . self::TABLE_NAME . " (user_id, token, persistent_token, expires)
85            VALUES (:user_id, :token, NULL, :expires_at)
86        ");
87        $stmt->execute([
88            'user_id' => $userId,
89            'token' => $hashedToken,
90            'expires_at' => $expiresAt
91        ]);
92
93        return $plaintextToken;
94    }
95
96    /**
97     * Validate a remember-me token and return user ID if valid.
98     * Implements token rotation: old token is invalidated, new one is issued.
99     *
100     * @param string $plaintextToken Token from cookie
101     * @return array|null ['userId' => int, 'newToken' => string] or null if invalid
102     */
103    public function validateAndRotate(string $plaintextToken): ?array
104    {
105        $hashedToken = hash('sha256', $plaintextToken);
106
107        $stmt = $this->db->prepare("
108            SELECT user_id, expires
109            FROM " . self::TABLE_NAME . "
110            WHERE token = :token
111        ");
112        $stmt->execute(['token' => $hashedToken]);
113        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
114
115        if (!$row) {
116            return null;
117        }
118
119        // Check expiration
120        if (strtotime($row['expires']) < time()) {
121            // Token expired, delete it
122            $this->deleteToken($hashedToken);
123            return null;
124        }
125
126        $userId = (int)$row['user_id'];
127
128        // Token rotation: delete old token, create new one
129        $this->deleteToken($hashedToken);
130        $newToken = $this->createToken($userId);
131
132        return [
133            'userId' => $userId,
134            'newToken' => $newToken
135        ];
136    }
137
138    /**
139     * Check if token is valid WITHOUT rotating (for session validation)
140     *
141     * @param string $plaintextToken Token from cookie
142     * @return int|null User ID if valid, null otherwise
143     */
144    public function validateToken(string $plaintextToken): ?int
145    {
146        $hashedToken = hash('sha256', $plaintextToken);
147
148        $stmt = $this->db->prepare("
149            SELECT user_id, expires
150            FROM " . self::TABLE_NAME . "
151            WHERE token = :token
152        ");
153        $stmt->execute(['token' => $hashedToken]);
154        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
155
156        if (!$row) {
157            return null;
158        }
159
160        // Check expiration
161        if (strtotime($row['expires']) < time()) {
162            return null;
163        }
164
165        return (int)$row['user_id'];
166    }
167
168    /**
169     * Revoke all remember-me tokens for a user.
170     * Call this on password change, logout from all devices, etc.
171     *
172     * @param int $userId User ID
173     */
174    public function revokeAllForUser(int $userId): void
175    {
176        $stmt = $this->db->prepare("
177            DELETE FROM " . self::TABLE_NAME . "
178            WHERE user_id = :user_id
179        ");
180        $stmt->execute(['user_id' => $userId]);
181    }
182
183    /**
184     * Delete a specific token by its hash
185     *
186     * @param string $hashedToken Hashed token
187     */
188    private function deleteToken(string $hashedToken): void
189    {
190        $stmt = $this->db->prepare("
191            DELETE FROM " . self::TABLE_NAME . "
192            WHERE token = :token
193        ");
194        $stmt->execute(['token' => $hashedToken]);
195    }
196
197    /**
198     * Set the remember-me cookie with secure flags
199     *
200     * @param string $token Plaintext token
201     */
202    public function setCookie(string $token): void
203    {
204        $expires = time() + (self::TOKEN_EXPIRY_DAYS * 24 * 60 * 60);
205
206        // Determine if we should use secure flag
207        $secure = $this->isSecureConnection();
208
209        setcookie(self::COOKIE_NAME, $token, [
210            'expires' => $expires,
211            'path' => '/',
212            'secure' => $secure,
213            'httponly' => true,
214            'samesite' => 'Strict'
215        ]);
216    }
217
218    /**
219     * Clear the remember-me cookie
220     */
221    public function clearCookie(): void
222    {
223        $secure = $this->isSecureConnection();
224
225        setcookie(self::COOKIE_NAME, '', [
226            'expires' => time() - 3600,
227            'path' => '/',
228            'secure' => $secure,
229            'httponly' => true,
230            'samesite' => 'Strict'
231        ]);
232
233        // Also unset from current request
234        unset($_COOKIE[self::COOKIE_NAME]);
235    }
236
237    /**
238     * Get token from cookie if present
239     *
240     * @return string|null Token or null if not set
241     */
242    public function getTokenFromCookie(): ?string
243    {
244        return $_COOKIE[self::COOKIE_NAME] ?? null;
245    }
246
247    /**
248     * Get the cookie name
249     *
250     * @return string Cookie name
251     */
252    public function getCookieName(): string
253    {
254        return self::COOKIE_NAME;
255    }
256
257    /**
258     * Check if the current connection is secure (HTTPS)
259     *
260     * @return bool
261     */
262    private function isSecureConnection(): bool
263    {
264        // Check APP_ENV
265        if (!empty($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'production') {
266            return true;
267        }
268
269        // Check HTTPS header
270        if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
271            return true;
272        }
273
274        // Check forwarded proto (for load balancers)
275        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
276            return true;
277        }
278
279        return false;
280    }
281
282    /**
283     * Clean up expired tokens (maintenance task)
284     * Should be called periodically via cron
285     *
286     * @return int Number of tokens deleted
287     */
288    public function cleanupExpiredTokens(): int
289    {
290        $stmt = $this->db->prepare("
291            DELETE FROM " . self::TABLE_NAME . "
292            WHERE expires < NOW()
293        ");
294        $stmt->execute();
295
296        return $stmt->rowCount();
297    }
298}