Files
feuerwerk/api/lib/SessionStore.php
T

97 lines
3.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Logins, kept as one file per session in `data/sessions/`, named after the SHA-256 of the token
* the browser holds in its cookie – so the file on disk cannot be replayed as a token.
*
* Deliberately not PHP's own session handler: those expire after `session.gc_maxlifetime`
* (24 minutes by default), and a login here has to outlive a deployment and a night, since
* `npm run build` replaces the editor under the very tab that is signed in.
*/
final class SessionStore
{
/** How long a login lasts without being used; every request that finds it pushes it out again. */
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
public const COOKIE = 'game_maker_session';
public function __construct(private readonly string $dir) {}
/** Starts a session for `$username` and returns the token for the cookie. */
public function create(string $username): string
{
$this->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);
}
}
/** 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 || file_put_contents($path, $json, LOCK_EX) === false) {
throw new RuntimeException('Cannot write session file');
}
@chmod($path, 0640);
}
}