User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60ea56a2ce
commit
8e4bb6e1f9
38 files changed
+1530
-139
No files matched your search
+62
-3
@@ -7,6 +7,13 @@ declare(strict_types=1);
|
||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||
*
|
||||
* GET ?r=auth/session → {user} (null while nobody is signed in)
|
||||
* POST ?r=auth/login {username,password} → {user} | 401
|
||||
* POST ?r=auth/logout → 204
|
||||
*
|
||||
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
|
||||
* of somebody else answers like one that is not there.
|
||||
*
|
||||
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||
@@ -36,13 +43,16 @@ declare(strict_types=1);
|
||||
* `data/sounds/` as static files; the API only writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/PluginStore.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/SessionStore.php';
|
||||
require __DIR__ . '/lib/SoundStore.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
require __DIR__ . '/lib/UserStore.php';
|
||||
|
||||
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
|
||||
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
||||
@@ -61,9 +71,47 @@ set_error_handler(static function (int $severity, string $message, string $file,
|
||||
});
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeProjects(array $parts, string $method): never
|
||||
function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
{
|
||||
$store = new Store(dirname(__DIR__) . '/data/projects');
|
||||
$what = $parts[1] ?? '';
|
||||
|
||||
if ($what === 'session') {
|
||||
if ($method !== 'GET') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
// Always 200, never 401: "nobody is signed in" is an answer, not a failure, and the
|
||||
// login screen asks this before it can know either way.
|
||||
Response::json(['user' => $auth->user()]);
|
||||
}
|
||||
|
||||
if ($what === 'login') {
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$body = Response::body();
|
||||
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
|
||||
if ($user === null) {
|
||||
// One message for both halves: which of the two was wrong is the attacker's question.
|
||||
Response::error('Wrong user name or password', 401);
|
||||
}
|
||||
Response::json(['user' => $user]);
|
||||
}
|
||||
|
||||
if ($what === 'logout') {
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$auth->logOut();
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeProjects(array $parts, string $method, string $owner): never
|
||||
{
|
||||
$store = new Store(dirname(__DIR__) . '/data/projects', $owner);
|
||||
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
@@ -274,8 +322,19 @@ try {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
$auth = new Auth(
|
||||
new UserStore(dirname(__DIR__) . '/data/users.json'),
|
||||
new SessionStore(dirname(__DIR__) . '/data/sessions'),
|
||||
);
|
||||
if ($parts[0] === 'auth') {
|
||||
routeAuth($parts, $method, $auth);
|
||||
}
|
||||
// Images, fonts, sounds and plugins are shared between accounts; being signed in is still the
|
||||
// price of admission, so an uploaded file cannot be dropped in by a stranger.
|
||||
$user = $auth->require();
|
||||
|
||||
match ($parts[0]) {
|
||||
'projects' => routeProjects($parts, $method),
|
||||
'projects' => routeProjects($parts, $method, $user),
|
||||
'textures' => routeTextures($parts, $method),
|
||||
'fonts' => routeFonts($parts, $method),
|
||||
'sounds' => routeSounds($parts, $method),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Who is asking. The browser sends the session cookie; the check tools log in the same way and
|
||||
* send the same cookie, so there is exactly one way into the API.
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserStore $users,
|
||||
private readonly SessionStore $sessions,
|
||||
) {}
|
||||
|
||||
/** The signed-in user, or null when nobody is. */
|
||||
public function user(): ?string
|
||||
{
|
||||
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
|
||||
return $token === '' ? null : $this->sessions->user($token);
|
||||
}
|
||||
|
||||
/** The signed-in user, or a 401 answer – every route but `auth/*` goes through here. */
|
||||
public function require(): string
|
||||
{
|
||||
$user = $this->user();
|
||||
if ($user === null) {
|
||||
Response::error('Sign in first', 401);
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** Checks the pair, starts a session and sets the cookie. Null when the pair is wrong. */
|
||||
public function logIn(string $username, string $password): ?string
|
||||
{
|
||||
$name = $this->users->verify($username, $password);
|
||||
if ($name === null) {
|
||||
return null;
|
||||
}
|
||||
$this->setCookie($this->sessions->create($name), time() + SessionStore::LIFETIME_SECONDS);
|
||||
return $name;
|
||||
}
|
||||
|
||||
public function logOut(): void
|
||||
{
|
||||
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
|
||||
if ($token !== '') {
|
||||
$this->sessions->destroy($token);
|
||||
}
|
||||
$this->setCookie('', time() - 3600);
|
||||
}
|
||||
|
||||
private function setCookie(string $value, int $expires): void
|
||||
{
|
||||
setcookie(SessionStore::COOKIE, $value, [
|
||||
'expires' => $expires,
|
||||
'path' => self::basePath(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the editor is served under (`/game-maker/`), read off the script rather than
|
||||
* written down: the same checkout answers under a different prefix in CI.
|
||||
*/
|
||||
private static function basePath(): string
|
||||
{
|
||||
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/game-maker/api/index.php');
|
||||
$base = dirname(dirname($script));
|
||||
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
+27
-8
@@ -12,14 +12,20 @@ final class StaleRevisionException extends RuntimeException
|
||||
|
||||
/**
|
||||
* File-based project store: one JSON document per project in `data/projects/{id}.json`.
|
||||
* Each file wraps the project with metadata: { id, name, revision, updatedAt, project }.
|
||||
* Each file wraps the project with metadata: { id, name, owner, revision, updatedAt, project }.
|
||||
* Writes are atomic (temp file + rename) and guarded by an integer revision so that a
|
||||
* stale client (second tab, old session) cannot silently overwrite newer work.
|
||||
*
|
||||
* A store belongs to one signed-in user: it lists, reads and writes only the projects whose
|
||||
* `owner` is that user. Someone else's project is answered as missing rather than as forbidden,
|
||||
* because "this id exists" is already more than a stranger should learn.
|
||||
*/
|
||||
final class Store
|
||||
{
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $dir,
|
||||
private readonly string $owner,
|
||||
) {
|
||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||||
throw new RuntimeException("Cannot create data directory {$this->dir}");
|
||||
}
|
||||
@@ -41,7 +47,7 @@ final class Store
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $file) {
|
||||
$doc = $this->readFile($file);
|
||||
if ($doc === null) {
|
||||
if ($doc === null || $doc['owner'] !== $this->owner) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
@@ -55,13 +61,13 @@ final class Store
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array}|null */
|
||||
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array}|null */
|
||||
public function load(string $id): ?array
|
||||
{
|
||||
return $this->readFile($this->path($id));
|
||||
return $this->own($this->readFile($this->path($id)));
|
||||
}
|
||||
|
||||
/** @return array{id:string,name:string,revision:int,updatedAt:string,project:array} */
|
||||
/** @return array{id:string,name:string,owner:string,revision:int,updatedAt:string,project:array} */
|
||||
public function create(string $name): array
|
||||
{
|
||||
do {
|
||||
@@ -93,7 +99,7 @@ final class Store
|
||||
throw new RuntimeException('Cannot acquire project lock');
|
||||
}
|
||||
try {
|
||||
$current = $this->readFile($path);
|
||||
$current = $this->own($this->readFile($path));
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -114,10 +120,19 @@ final class Store
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
if ($this->load($id) === null) {
|
||||
return false;
|
||||
}
|
||||
$path = $this->path($id);
|
||||
return file_exists($path) && unlink($path);
|
||||
}
|
||||
|
||||
/** The document, or null when it belongs to someone else. */
|
||||
private function own(?array $doc): ?array
|
||||
{
|
||||
return $doc !== null && $doc['owner'] === $this->owner ? $doc : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
{
|
||||
return $this->dir . '/' . $id . '.json';
|
||||
@@ -128,6 +143,7 @@ final class Store
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'owner' => $this->owner,
|
||||
'revision' => $revision,
|
||||
'updatedAt' => gmdate('c'),
|
||||
'project' => $project,
|
||||
@@ -145,6 +161,9 @@ final class Store
|
||||
return null;
|
||||
}
|
||||
$doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled';
|
||||
// A file without an owner belongs to nobody and is therefore invisible to everybody –
|
||||
// the safe way round, and the one that shows up straight away if a migration is missed.
|
||||
$doc['owner'] = is_string($doc['owner'] ?? null) ? $doc['owner'] : '';
|
||||
$doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : '';
|
||||
$doc['revision'] = (int) $doc['revision'];
|
||||
return $doc;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The accounts, as one JSON file: `data/users.json` holding `{username, password}` per user,
|
||||
* where `password` is a hash from `password_hash` – the plain one is never stored.
|
||||
*
|
||||
* Accounts are made from the command line (`tools/make-user.php`), not through the API: signing
|
||||
* up is a follow-up of its own, and until it exists nobody may create an account over the wire.
|
||||
*/
|
||||
final class UserStore
|
||||
{
|
||||
public function __construct(private readonly string $file) {}
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
|
||||
}
|
||||
|
||||
/** The name as stored, or null when the pair does not match an account. */
|
||||
public function verify(string $username, string $password): ?string
|
||||
{
|
||||
$user = $this->find($username);
|
||||
if ($user === null || !password_verify($password, $user['password'])) {
|
||||
return null;
|
||||
}
|
||||
return $user['username'];
|
||||
}
|
||||
|
||||
public function exists(string $username): bool
|
||||
{
|
||||
return $this->find($username) !== null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function names(): array
|
||||
{
|
||||
return array_map(static fn(array $u): string => $u['username'], $this->all());
|
||||
}
|
||||
|
||||
/** Adds the account or replaces the password of one that is already there. */
|
||||
public function put(string $username, string $password): void
|
||||
{
|
||||
if (!self::isValidName($username)) {
|
||||
throw new InvalidArgumentException("Invalid user name: {$username}");
|
||||
}
|
||||
$users = array_values(
|
||||
array_filter(
|
||||
$this->all(),
|
||||
static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0,
|
||||
),
|
||||
);
|
||||
$users[] = ['username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT)];
|
||||
usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username']));
|
||||
$this->write($users);
|
||||
}
|
||||
|
||||
/** @return array{username:string,password:string}|null */
|
||||
private function find(string $username): ?array
|
||||
{
|
||||
foreach ($this->all() as $user) {
|
||||
if (strcasecmp($user['username'], $username) === 0) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<array{username:string,password:string}> */
|
||||
private function all(): array
|
||||
{
|
||||
if (!is_file($this->file)) {
|
||||
return [];
|
||||
}
|
||||
$raw = file_get_contents($this->file);
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
if (!is_array($data) || !is_array($data['users'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
$users = [];
|
||||
foreach ($data['users'] as $user) {
|
||||
if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) {
|
||||
$users[] = ['username' => $user['username'], 'password' => $user['password']];
|
||||
}
|
||||
}
|
||||
return $users;
|
||||
}
|
||||
|
||||
/** @param list<array{username:string,password:string}> $users */
|
||||
private function write(array $users): void
|
||||
{
|
||||
$dir = dirname($this->file);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create data directory {$dir}");
|
||||
}
|
||||
$json = json_encode(['users' => $users], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Cannot encode users');
|
||||
}
|
||||
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException('Cannot write users file');
|
||||
}
|
||||
@chmod($this->file, 0640);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user