prepare( 'INSERT INTO trusted_devices(user_id,selector,token_hash,device_name,user_agent,ip_address,expires_at) VALUES(?,?,?,?,?,?,?)', ) ->execute([ $userId, $selector, hash('sha256', $token), mb_substr($name, 0, 100), mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null, $_SERVER['REMOTE_ADDR'] ?? null, date('Y-m-d H:i:s', $expires), ]); self::cookie($selector . '.' . bin2hex($token), $expires); } public static function authenticate(): ?int { $raw = (string) ($_COOKIE[self::COOKIE] ?? ''); if (!preg_match('/^([a-f0-9]{18})\.([a-f0-9]{64})$/', $raw, $m)) { return null; } try { $s = DB::pdo()->prepare( "SELECT td.*,u.active FROM trusted_devices td JOIN users u ON u.id=td.user_id WHERE td.selector=? AND td.revoked_at IS NULL AND datetime(td.expires_at)>datetime('now') AND (td.last_used_at IS NULL OR datetime(td.last_used_at)>datetime('now','-30 days'))", ); $s->execute([$m[1]]); $row = $s->fetch(PDO::FETCH_ASSOC); if ( !$row || !hash_equals((string) $row['token_hash'], hash('sha256', hex2bin($m[2]) ?: '')) || (int) $row['active'] !== 1 ) { self::forget(); return null; } $new = random_bytes(32); DB::pdo() ->prepare( "UPDATE trusted_devices SET token_hash=?,last_used_at=datetime('now'),ip_address=? WHERE id=?", ) ->execute([hash('sha256', $new), $_SERVER['REMOTE_ADDR'] ?? null, $row['id']]); self::cookie($m[1] . '.' . bin2hex($new), strtotime((string) $row['expires_at'])); return (int) $row['user_id']; } catch (Throwable) { return null; } } public static function currentSelector(): ?string { $raw = (string) ($_COOKIE[self::COOKIE] ?? ''); return preg_match('/^([a-f0-9]{18})\./', $raw, $m) ? $m[1] : null; } public static function revokeCurrent(): void { $selector = self::currentSelector(); if ($selector) { try { DB::pdo() ->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE selector=?") ->execute([$selector]); } catch (Throwable) { } } self::forget(); } public static function devices(?int $userId = null): array { $userId ??= Auth::id(); $s = DB::pdo()->prepare( 'SELECT * FROM trusted_devices WHERE user_id=? ORDER BY revoked_at IS NULL DESC,last_used_at DESC', ); $s->execute([$userId]); return $s->fetchAll(PDO::FETCH_ASSOC); } public static function revoke(int $id, int $userId, bool $admin = false): void { $sql = 'UPDATE trusted_devices SET revoked_at=datetime(\'now\') WHERE id=:id' . ($admin ? '' : ' AND user_id=:uid'); $params = [':id' => $id]; if (!$admin) { $params[':uid'] = $userId; } DB::pdo()->prepare($sql)->execute($params); if ( self::currentSelector() && self::currentSelector() === (string) DB::pdo() ->query('SELECT selector FROM trusted_devices WHERE id=' . (int) $id) ->fetchColumn() ) { self::forget(); } } private static function deviceLabel(): string { $ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); if (str_contains($ua, 'globinoursandroid')) { return 'Application Android'; } if (str_contains($ua, 'android')) { return 'Smartphone ou tablette Android'; } if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad')) { return 'iPhone ou iPad'; } return 'Navigateur web'; } private static function cookie(string $value, int $expires): void { setcookie(self::COOKIE, $value, [ 'expires' => $expires, 'path' => '/', 'secure' => Auth::isSecureRequest(), 'httponly' => true, 'samesite' => 'Strict', ]); $_COOKIE[self::COOKIE] = $value; } private static function forget(): void { setcookie(self::COOKIE, '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => Auth::isSecureRequest(), 'httponly' => true, 'samesite' => 'Strict', ]); unset($_COOKIE[self::COOKIE]); } }