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 ));