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
@@ -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