sweep(); $token = bin2hex(random_bytes(32)); $this->write($token, ['username' => $username, 'createdAt' => time(), 'seenAt' => time()]); return $token; } /** The user behind a token, or null when it is unknown or has expired. */ public function user(string $token): ?string { $path = $this->path($token); if ($path === null || !is_file($path)) { return null; } $raw = file_get_contents($path); $data = $raw === false ? null : json_decode($raw, true); if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['seenAt'] ?? null)) { return null; } if (time() - $data['seenAt'] > self::LIFETIME_SECONDS) { @unlink($path); return null; } // Touched at most once an hour: every request rewriting the file would cost a write per // autosave, and the lifetime is measured in weeks. if (time() - $data['seenAt'] > 3600) { $data['seenAt'] = time(); $this->write($token, $data); } return $data['username']; } public function destroy(string $token): void { $path = $this->path($token); if ($path !== null) { @unlink($path); } } /** * Ends every login of `$username` but the one behind `$keep` – what a password change does, so * that a session started with the old password cannot outlive it. Returns how many were ended. */ public function destroyAllFor(string $username, string $keep = ''): int { $keepPath = $keep === '' ? null : $this->path($keep); $ended = 0; foreach (glob($this->dir . '/*.json') ?: [] as $file) { if ($file === $keepPath) { continue; } $raw = file_get_contents($file); $data = $raw === false ? null : json_decode($raw, true); if (is_array($data) && strcasecmp((string) ($data['username'] ?? ''), $username) === 0) { @unlink($file); $ended++; } } return $ended; } /** Throws out what has timed out, so the directory does not grow with every login. */ private function sweep(): void { foreach (glob($this->dir . '/*.json') ?: [] as $file) { if (time() - (int) @filemtime($file) > self::LIFETIME_SECONDS) { @unlink($file); } } } private function path(string $token): ?string { if (!preg_match('/^[a-f0-9]{64}$/', $token)) { return null; } return $this->dir . '/' . hash('sha256', $token) . '.json'; } private function write(string $token, array $data): void { if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { throw new RuntimeException("Cannot create session directory {$this->dir}"); } $path = $this->path($token); if ($path === null) { throw new RuntimeException('Invalid session token'); } $json = json_encode($data, JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Cannot encode session'); } // Replaced rather than rewritten in place: a request reading it meanwhile would find it // empty, and an empty session is a signed-out one. AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'session file'); } }