Changing the password: the Logout button at the far right of the toolbar became an Account menu that names the signed-in account in an entry of its own that cannot be picked, with Change password... above Logout, and the new dialog (src/editor/auth/PasswordDialog.ts) asks for the current password, the new one and its repetition, all three as bullets, and stays standing when the server refuses so that it can say why, unlike the other form dialogs; the rules for empty, too short, new equal to old and a differing repetition sit as the pure function passwordProblem() next to loginProblem(), the API gained POST ?r=auth/password which answers 204, insists on the current password although a session is there, enforces a minimum length of 8 in UserStore::MIN_PASSWORD_LENGTH and again in the client so the mask can answer without asking the server, and the new SessionStore::destroyAllFor($user, $keep) ends every other session of the account while the tab that changed the password keeps its own; the placeholders admin/admin and user/user stay as they are, which the length check does not touch because it only runs when a password is set through the API; e2e 45 grew by 8 checks for the menu, the dialog, both refusals and signing in with the old and the new password, and it sets the e2e account back at the end - a run dying in between leaves it on <password>-tmp, which a make-user.php call repairs (#182)
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m18s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 13:46:40 +02:00
1 parent 1ef497fa17
commit c830c7a3aa
15 files changed
+473 -18

No files matched your search

+21
View File
@@ -10,6 +10,9 @@ declare(strict_types=1);
* GET ?r=auth/session → {user} (null while nobody is signed in)
* POST ?r=auth/login {username,password} → {user} | 401
* POST ?r=auth/logout → 204
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
* (the only auth route that needs a session; ends the account's
* other logins, keeps the one asking)
*
* 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.
@@ -97,6 +100,24 @@ function routeAuth(array $parts, string $method, Auth $auth): never
Response::json(['user' => $user]);
}
if ($what === 'password') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
// The one route under `auth/` that needs a session: it changes the password of whoever is
// signed in, so there has to be somebody.
$user = $auth->require();
$body = Response::body();
$new = (string) ($body['newPassword'] ?? '');
if (!UserStore::isValidPassword($new)) {
Response::error('The new password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
}
if (!$auth->changePassword($user, (string) ($body['currentPassword'] ?? ''), $new)) {
Response::error('Wrong password', 401);
}
Response::noContent();
}
if ($what === 'logout') {
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
+25 -1
View File
@@ -42,13 +42,37 @@ final class Auth
public function logOut(): void
{
$token = (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
$token = $this->token();
if ($token !== '') {
$this->sessions->destroy($token);
}
$this->setCookie('', time() - 3600);
}
/**
* Sets a new password for `$user`, who has to type the old one again: a session alone is not
* enough, or somebody else's open tab would be all it takes to take an account over.
*
* Every other login of the account ends with it – a session that was started with the old
* password has no business outliving it. The one asking keeps theirs.
*
* @return bool False when the current password does not match; the caller answers 401 then.
*/
public function changePassword(string $user, string $current, string $new): bool
{
if ($this->users->verify($user, $current) === null) {
return false;
}
$this->users->put($user, $new);
$this->sessions->destroyAllFor($user, $this->token());
return true;
}
private function token(): string
{
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
}
private function setCookie(string $value, int $expires): void
{
setcookie(SessionStore::COOKIE, $value, [
+22
View File
@@ -60,6 +60,28 @@ final class SessionStore
}
}
/**
* 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
{
+12
View File
@@ -10,6 +10,13 @@ declare(strict_types=1);
*/
final class UserStore
{
/**
* The shortest password an account may be given. The sign-in form knows the same number
* (`loginRules.ts`) so it can say so without asking, but this is the one that decides: a
* client is free to send anything.
*/
public const MIN_PASSWORD_LENGTH = 8;
public function __construct(private readonly string $file) {}
public static function isValidName(string $name): bool
@@ -17,6 +24,11 @@ final class UserStore
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
}
public static function isValidPassword(string $password): bool
{
return strlen($password) >= self::MIN_PASSWORD_LENGTH;
}
/** The name as stored, or null when the pair does not match an account. */
public function verify(string $username, string $password): ?string
{