diff --git a/src/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.php b/src/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.php index 900963d1fffa7..01613ec299b0b 100644 --- a/src/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.php +++ b/src/Symfony/Component/Security/Core/User/PasswordAuthenticatedUserInterface.php @@ -14,6 +14,26 @@ /** * For users that can be authenticated using a password. * + * The __serialize/__unserialize() magic methods can be implemented on the user + * class to prevent hashed passwords from being put in the session storage. + * If the password is not stored at all in the session, getPassword() should + * return null after unserialization, and then, changing the user's password + * won't invalidate its sessions. + * In order to invalidate the user sessions while not storing the password hash + * in the session, it's also possible to hash the password hash before + * serializing it; crc32c is the only algorithm supported. + * For example: + * + * public function __serialize(): array + * { + * $data = (array) $this; + * $data["\0".self::class."\0password"] = hash('crc32c', $this->password); + * + * return $data; + * } + * + * Implement EquatableInteface if you need another logic. + * * @author Robin Chalas * @author Wouter de Jong */ @@ -23,9 +43,6 @@ interface PasswordAuthenticatedUserInterface * Returns the hashed password used to authenticate the user. * * Usually on authentication, a plain-text password will be compared to this value. - * - * The __serialize/__unserialize() magic methods can be implemented on the user - * class to prevent hashed passwords from being put in the session storage. */ public function getPassword(): ?string; } diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 568df817067bd..3a4f694af5027 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add encryption support to `OidcTokenHandler` (JWE) * Replace `$hideAccountStatusExceptions` argument with `$exposeSecurityErrors` in `AuthenticatorManager` constructor * Add argument `$identifierNormalizer` to `UserBadge::__construct()` to allow normalizing the identifier + * Support hashing the hashed password using crc32c when putting the user in the session 7.2 --- diff --git a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php index bbdbd4b7ba81a..05a4a84b3918a 100644 --- a/src/Symfony/Component/Security/Http/Firewall/ContextListener.php +++ b/src/Symfony/Component/Security/Http/Firewall/ContextListener.php @@ -292,9 +292,16 @@ private static function hasUserChanged(UserInterface $originalUser, TokenInterfa } if ($originalUser instanceof PasswordAuthenticatedUserInterface || $refreshedUser instanceof PasswordAuthenticatedUserInterface) { - if (!$originalUser instanceof PasswordAuthenticatedUserInterface - || !$refreshedUser instanceof PasswordAuthenticatedUserInterface - || $refreshedUser->getPassword() !== ($originalUser->getPassword() ?? $refreshedUser->getPassword()) + if (!$originalUser instanceof PasswordAuthenticatedUserInterface || !$refreshedUser instanceof PasswordAuthenticatedUserInterface) { + return true; + } + + $originalPassword = $originalUser->getPassword(); + $refreshedPassword = $refreshedUser->getPassword(); + + if (null !== $originalPassword + && $refreshedPassword !== $originalPassword + && (8 !== \strlen($originalPassword) || hash('crc32c', $refreshedPassword ?? $originalPassword) !== $originalPassword) ) { return true; } @@ -303,7 +310,7 @@ private static function hasUserChanged(UserInterface $originalUser, TokenInterfa return true; } - if ($originalUser instanceof LegacyPasswordAuthenticatedUserInterface && $refreshedUser instanceof LegacyPasswordAuthenticatedUserInterface && $originalUser->getSalt() !== $refreshedUser->getSalt()) { + if ($originalUser instanceof LegacyPasswordAuthenticatedUserInterface && $originalUser->getSalt() !== $refreshedUser->getSalt()) { return true; } } diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php index 11de7d5400f83..7c3248c716bbc 100644 --- a/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/Firewall/ContextListenerTest.php @@ -377,9 +377,14 @@ public function testOnKernelResponseRemoveListener() $this->assertEmpty($dispatcher->getListeners()); } - public function testRemovingPasswordFromSessionDoesntInvalidateTheToken() + /** + * @testWith [true] + * [false] + * [null] + */ + public function testNullOrHashedPasswordInSessionDoesntInvalidateTheToken(?bool $hashPassword) { - $user = new CustomUser('user', ['ROLE_USER'], 'pass'); + $user = new CustomUser('user', ['ROLE_USER'], 'pass', $hashPassword); $userProvider = $this->createMock(UserProviderInterface::class); $userProvider->expects($this->once()) diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/CustomUser.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/CustomUser.php index 16afc53987f93..9d6e29e616394 100644 --- a/src/Symfony/Component/Security/Http/Tests/Fixtures/CustomUser.php +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/CustomUser.php @@ -19,7 +19,8 @@ final class CustomUser implements UserInterface, PasswordAuthenticatedUserInterf public function __construct( private string $username, private array $roles, - private ?string $password = null, + private ?string $password, + private ?bool $hashPassword, ) { } @@ -44,6 +45,15 @@ public function eraseCredentials(): void public function __serialize(): array { - return [\sprintf("\0%s\0username", self::class) => $this->username]; + $data = (array) $this; + $passwordKey = \sprintf("\0%s\0password", self::class); + + if ($this->hashPassword) { + $data[$passwordKey] = hash('crc32c', $this->password); + } elseif (null !== $this->hashPassword) { + unset($data[$passwordKey]); + } + + return $data; } }