From ab2ceda3a3a9c27658c2488c66097ad4d3a60855 Mon Sep 17 00:00:00 2001 From: Abdul Majid <43958759+abdulmajidcse@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:54:41 +0600 Subject: [PATCH 1/2] fix(session): follow-ups to the database session driver - Record the stored password hash when a password is changed with withoutHook()->update(). The raw value was recorded before, but the model trims and casts it on save, so the device that changed its own password could be signed out by AuthenticateSession. - Add a --uuid option to session:table that generates a string(36) user_id column for apps with UUID or ULID user keys. - Correct the sessions() docblock: the list includes the current device. - Add upgrade notes for the database driver and AuthenticateSession. --- CHANGELOG.md | 12 +++++++++ src/Phaseolies/Auth/Authable.php | 15 +++++++---- src/Phaseolies/Auth/Security/Authenticate.php | 2 +- .../Console/Commands/SessionTableCommand.php | 19 ++++++++++--- .../stubs/session/create_sessions_table.stub | 3 +-- tests/Auth/LogoutOtherDevicesTest.php | 15 +++++++++++ tests/Console/SessionTableCommandTest.php | 27 +++++++++++++++++-- tests/Session/DatabaseStoreTest.php | 4 +-- 8 files changed, 82 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9f4b59..89d5c4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Release Notes +## Unreleased + +* fix(auth): record the stored password hash when a password is changed with `withoutHook()->update()`, so the device that changed it stays signed in +* feat(session): add a `--uuid` option to `session:table` for apps whose user keys are UUIDs or ULIDs + +### Upgrade notes for the database session driver and `AuthenticateSession` + +* **Turning on `AuthenticateSession` signs out existing logged-in users once.** Sessions created before v4.9.0 have no password fingerprint, so they are signed out on their next request. Users with a valid remember cookie are signed back in automatically. This is deliberate: the framework cannot tell which password an older session signed in with. +* **Only the `database` driver ends other devices' sessions directly, and only for the default auth actor.** With the `file` or `cookie` driver, or a non-default actor such as `admin`, `logoutOtherDevices()` signs other devices out only on routes that use the `AuthenticateSession` middleware. +* **Changing `APP_KEY` signs everyone out** on routes that use `AuthenticateSession`, because the password fingerprint is keyed by `app.key`. Rotating the key already invalidates encrypted and remember cookies. +* **UUID or ULID user keys:** generate the sessions migration with `php pool session:table --uuid`. The default `user_id` column is a big integer. + ## v4.9.0 - 2026-11-11 * feat: Rewrite sessions as a lazy, store-based layer by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/398 diff --git a/src/Phaseolies/Auth/Authable.php b/src/Phaseolies/Auth/Authable.php index 33111d30..9b583e47 100644 --- a/src/Phaseolies/Auth/Authable.php +++ b/src/Phaseolies/Auth/Authable.php @@ -152,14 +152,19 @@ public function update(array $attributes): bool if ($this->hooksSuppressed) { $name = $this->getAuthPasswordName(); - $this->recordPasswordChange( + if ( isset($this->attributes[$this->primaryKey]) && array_key_exists($name, $attributes) && in_array($name, $this->creatable, true) - && $attributes[$name] !== ($this->getOriginalAttributes()[$name] ?? null) - ? $attributes[$name] - : null - ); + ) { + // Normalise the value exactly as setAttribute() will, so the + // recorded hash is the one that ends up in the database. + $value = $this->castForSet($name, $this->sanitize($attributes[$name])); + + $this->recordPasswordChange( + $value !== ($this->getOriginalAttributes()[$name] ?? $this->attributes[$name] ?? null) ? $value : null + ); + } } return parent::update($attributes); diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 82ae6a8a..4ed24f79 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -663,7 +663,7 @@ public function refreshSessionFingerprint(): void } /** - * The other devices the user is signed in on, most recently active first. + * The devices the user is signed in on, this one included, most recently active first. * * @return array */ diff --git a/src/Phaseolies/Console/Commands/SessionTableCommand.php b/src/Phaseolies/Console/Commands/SessionTableCommand.php index b45b8e28..25645484 100644 --- a/src/Phaseolies/Console/Commands/SessionTableCommand.php +++ b/src/Phaseolies/Console/Commands/SessionTableCommand.php @@ -13,7 +13,7 @@ class SessionTableCommand extends Command * * @var string */ - protected $name = 'session:table'; + protected $name = 'session:table {--uuid}'; /** * The description of the console command. @@ -60,8 +60,8 @@ public function handle(): int $stub = (string) file_get_contents(__DIR__ . '/stubs/session/create_sessions_table.stub'); file_put_contents($file, str_replace( - ['{{ schema }}', '{{ table }}'], - [$this->schemaAccessor($connection), $table], + ['{{ schema }}', '{{ table }}', '{{ user_id }}'], + [$this->schemaAccessor($connection), $table, $this->userIdColumn((bool) $this->option('uuid'))], $stub )); @@ -89,6 +89,19 @@ protected function schemaAccessor(?string $connection): string : 'Schema::connection(' . var_export($connection, true) . ')->'; } + /** + * The user_id column definition, matching the type of the user's primary key. + * + * @param bool $uuid + * @return string + */ + protected function userIdColumn(bool $uuid): string + { + return $uuid + ? "\$table->string('user_id', 36)->nullable()->index();" + : "\$table->unsignedBigInteger('user_id')->nullable()->index();"; + } + /** * Path of the application's migrations. * diff --git a/src/Phaseolies/Console/Commands/stubs/session/create_sessions_table.stub b/src/Phaseolies/Console/Commands/stubs/session/create_sessions_table.stub index bf289855..b56c9c3c 100644 --- a/src/Phaseolies/Console/Commands/stubs/session/create_sessions_table.stub +++ b/src/Phaseolies/Console/Commands/stubs/session/create_sessions_table.stub @@ -15,8 +15,7 @@ return new class extends Migration { {{ schema }}create('{{ table }}', function (Blueprint $table) { $table->string('id', 128)->primary(); - // For UUID or ULID user keys, use: $table->string('user_id', 36)->nullable()->index(); - $table->unsignedBigInteger('user_id')->nullable()->index(); + {{ user_id }} $table->string('ip_address', 45)->nullable(); $table->text('user_agent')->nullable(); $table->longText('payload'); diff --git a/tests/Auth/LogoutOtherDevicesTest.php b/tests/Auth/LogoutOtherDevicesTest.php index e9564f02..c75b6954 100644 --- a/tests/Auth/LogoutOtherDevicesTest.php +++ b/tests/Auth/LogoutOtherDevicesTest.php @@ -719,6 +719,21 @@ public function test_a_password_saved_without_hooks_keeps_a_parallel_request_sig $this->assertTrue($this->isCurrentAndSignedIn($laptop)); } + public function test_a_password_updated_without_hooks_is_recorded_as_the_model_stores_it(): void + { + $laptop = $this->signIn('Laptop'); + $hash = password_hash('a new password', PASSWORD_BCRYPT, ['cost' => 4]); + + [$laptop] = $this->onDevice($laptop, function (Authenticate $auth) use ($hash): void { + // The model trims the value before saving it, so the hash recorded + // as pending must be the trimmed one, not the value passed in. + $auth->user()->withoutHook()->update(['password' => " {$hash} "]); + }); + + $this->assertSame($hash, $this->storedPassword()); + $this->assertTrue($this->isCurrentAndSignedIn($laptop)); + } + public function test_changing_another_users_password_leaves_this_session_alone(): void { Database::getPdoInstance(self::CONNECTION) diff --git a/tests/Console/SessionTableCommandTest.php b/tests/Console/SessionTableCommandTest.php index 06ece85f..a91f11fa 100644 --- a/tests/Console/SessionTableCommandTest.php +++ b/tests/Console/SessionTableCommandTest.php @@ -96,6 +96,29 @@ public function test_it_uses_the_default_connection_when_none_is_configured(): v $this->assertStringNotContainsString('Schema::connection', $migration); } + public function test_it_uses_an_integer_user_id_by_default(): void + { + $this->run_(); + + $migration = (string) file_get_contents(glob($this->dir . '/*_create_sessions_table.php')[0]); + + $this->assertStringContainsString("\$table->unsignedBigInteger('user_id')->nullable()->index();", $migration); + $this->assertStringNotContainsString("\$table->string('user_id'", $migration); + $this->assertStringNotContainsString('{{ user_id }}', $migration); + } + + public function test_the_uuid_option_uses_a_string_user_id(): void + { + $tester = $this->run_(['--uuid' => true]); + + $migration = (string) file_get_contents(glob($this->dir . '/*_create_sessions_table.php')[0]); + + $this->assertSame(0, $tester->getStatusCode()); + $this->assertStringContainsString("\$table->string('user_id', 36)->nullable()->index();", $migration); + $this->assertStringNotContainsString('unsignedBigInteger(\'user_id\')', $migration); + $this->assertStringNotContainsString('{{ user_id }}', $migration); + } + public function test_it_rejects_a_schema_qualified_table_name(): void { Config::set('session.table', 'app.sessions'); @@ -116,7 +139,7 @@ public function test_it_rejects_an_unsafe_table_name(): void $this->assertSame([], glob($this->dir . '/*.php') ?: []); } - private function run_(): CommandTester + private function run_(array $input = []): CommandTester { $directory = $this->dir; @@ -133,7 +156,7 @@ protected function migrationPath(): string }; $tester = new CommandTester($command); - $tester->execute([], ['interactive' => false, 'decorated' => false]); + $tester->execute($input, ['interactive' => false, 'decorated' => false]); return $tester; } diff --git a/tests/Session/DatabaseStoreTest.php b/tests/Session/DatabaseStoreTest.php index a2e1a1a5..5f5a02bb 100644 --- a/tests/Session/DatabaseStoreTest.php +++ b/tests/Session/DatabaseStoreTest.php @@ -558,8 +558,8 @@ protected function migrate(): void // As generated for a configured session.connection. file_put_contents($file, str_replace( - ['{{ schema }}', '{{ table }}'], - ["Schema::connection('" . static::CONNECTION . "')->", static::TABLE], + ['{{ schema }}', '{{ table }}', '{{ user_id }}'], + ["Schema::connection('" . static::CONNECTION . "')->", static::TABLE, "\$table->unsignedBigInteger('user_id')->nullable()->index();"], $stub )); From 72a71af834ebe80a994ed23106e76a5ece3436ad Mon Sep 17 00:00:00 2001 From: Abdul Majid <43958759+abdulmajidcse@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:23:48 +0600 Subject: [PATCH 2/2] chore: revert CHANGELOG edits, it is updated on release --- CHANGELOG.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d5c4bb..1a9f4b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,5 @@ # Release Notes -## Unreleased - -* fix(auth): record the stored password hash when a password is changed with `withoutHook()->update()`, so the device that changed it stays signed in -* feat(session): add a `--uuid` option to `session:table` for apps whose user keys are UUIDs or ULIDs - -### Upgrade notes for the database session driver and `AuthenticateSession` - -* **Turning on `AuthenticateSession` signs out existing logged-in users once.** Sessions created before v4.9.0 have no password fingerprint, so they are signed out on their next request. Users with a valid remember cookie are signed back in automatically. This is deliberate: the framework cannot tell which password an older session signed in with. -* **Only the `database` driver ends other devices' sessions directly, and only for the default auth actor.** With the `file` or `cookie` driver, or a non-default actor such as `admin`, `logoutOtherDevices()` signs other devices out only on routes that use the `AuthenticateSession` middleware. -* **Changing `APP_KEY` signs everyone out** on routes that use `AuthenticateSession`, because the password fingerprint is keyed by `app.key`. Rotating the key already invalidates encrypted and remember cookies. -* **UUID or ULID user keys:** generate the sessions migration with `php pool session:table --uuid`. The default `user_id` column is a big integer. - ## v4.9.0 - 2026-11-11 * feat: Rewrite sessions as a lazy, store-based layer by [@techmahedy](https://github.com/techmahedy) in https://github.com/doppar/framework/pull/398