acseo/change-password-bundle
Symfony bundle for managing user password history with FOSUserBundle: stores previous hashed passwords, forces change when passwords are older than 30 days, and optionally blocks reusing old passwords via a validation constraint.
Installation
composer require acseo/change-password-bundle:dev-master
Register the bundle in AppKernel.php:
new ACSEO\ChangePasswordBundle\ACSEOChangePasswordBundle(),
Configure Target User Entity
Update config.yml to map your user entity:
doctrine:
orm:
resolve_target_entities:
FOS\UserBundle\Model\User: AppBundle\Entity\User
Update Database
Run migrations to create the password_history table:
php app/console doctrine:schema:update --dump-sql
php app/console doctrine:schema:update --force
First Use Case
Trigger a password change via FOSUserBundle’s default flow (/change-password). The bundle will:
password_history./change-password if their password is older than 30 days.Password Change Hook
Extend FOS\UserBundle\Event\FilterUserResponseEvent to enforce policies:
// src/AppBundle/EventListener/ChangePasswordListener.php
class ChangePasswordListener
{
public function onChangePassword(FilterUserResponseEvent $event)
{
$user = $event->getUser();
$passwordManager = $this->container->get('fos_user.user_manager');
$passwordHistory = $this->container->get('acseo_change_password.password_history');
// Custom logic (e.g., block reused passwords)
if ($passwordHistory->wasPasswordUsed($user, $event->getNewPasswordPlain)) {
throw new \RuntimeException('Password already used.');
}
}
}
Register in services.yml:
services:
app.change_password_listener:
class: AppBundle\EventListener\ChangePasswordListener
tags:
- { name: kernel.event_listener, event: fos_user.change_password.success, method: onChangePassword }
Policy Enforcement
Override the 30-day redirect threshold in config.yml:
acseo_change_password:
password_expiration_days: 60 # Customize expiration
block_reused_passwords: true # Enable/disable reuse block
Manual Password History Checks Use the service to validate passwords in custom forms:
$passwordHistory = $this->get('acseo_change_password.password_history');
if ($passwordHistory->wasPasswordUsed($user, $plainPassword)) {
$form->addError(new FormError('Password reused.'));
}
use ACSEO\ChangePasswordBundle\Validator\Constraints\UniquePassword;
$builder->add('plainPassword', PasswordType::class, [
'constraints' => [
new UniquePassword(),
],
]);
PasswordHistory service to validate password changes in REST endpoints.UserManager to add pre-save hooks:
$userManager->setPassword($user, $plainPassword);
$passwordHistory->logPasswordChange($user, $plainPassword);
FOSUserBundle Dependency
FOS\UserBundle. If missing, password changes will fail silently.FOS\UserBundle is installed and configured before enabling this bundle.Password Hashing Mismatches
User entity uses a custom encoder (e.g., bcrypt vs. argon2), ensure the PasswordHistory entity uses the same encoder.services.yml:
acseo_change_password.password_history:
class: ACSEO\ChangePasswordBundle\Service\PasswordHistory
arguments:
- '@security.password_encoder'
Database Schema Conflicts
password_history already exists, --force may corrupt data. Always check --dump-sql first.--dump-sql to review changes before applying.Caching Issues
PasswordHistory service isn’t refreshed after changes.php app/console cache:clear
config.yml:
acseo_change_password:
debug: true # Logs to `dev.log`
password_history table directly:
SELECT * FROM password_history WHERE user_id = [USER_ID];
PasswordExpirationChecker service to customize logic:
// src/AppBundle/Service/CustomExpirationChecker.php
class CustomExpirationChecker extends \ACSEO\ChangePasswordBundle\Service\PasswordExpirationChecker
{
public function isPasswordExpired(UserInterface $user)
{
// Custom expiration rules (e.g., role-based)
return parent::isPasswordExpired($user) && $user->hasRole('ROLE_ADMIN');
}
}
Register as a replacement in services.yml:
services:
acseo_change_password.password_expiration_checker:
class: AppBundle\Service\CustomExpirationChecker
Custom Validation Add a validator to block passwords based on business rules (e.g., length, complexity):
// src/AppBundle/Validator/Constraints/PasswordPolicy.php
class PasswordPolicy extends Constraint
{
public $message = 'Password must be 12+ chars with symbols.';
}
Use with the bundle’s constraints:
$builder->add('plainPassword', PasswordType::class, [
'constraints' => [
new UniquePassword(),
new PasswordPolicy(),
],
]);
Event-Driven Extensions
Listen to acseo_change_password.password.logged to trigger side effects (e.g., notifications):
// src/AppBundle/EventListener/PasswordLogListener.php
class PasswordLogListener
{
public function onPasswordLogged(PasswordLoggedEvent $event)
{
$this->mailer->send('password_changed', [$event->getUser()->getEmail()]);
}
}
Register in services.yml:
services:
app.password_log_listener:
class: AppBundle\EventListener\PasswordLogListener
tags:
- { name: kernel.event_listener, event: acseo_change_password.password.logged, method: onPasswordLogged }
Multi-Tenant Support
Add a tenant_id column to password_history and filter queries in the PasswordHistory service:
public function logPasswordChange(UserInterface $user, $plainPassword)
{
$history = new PasswordHistory();
$history->setUser($user);
$history->setTenant($this->tenantService->getCurrentTenant());
$history->setPassword($this->encoder->encodePassword($user, $plainPassword));
$this->em->persist($history);
$this->em->flush();
}
How can I help you explore Laravel packages today?