Skip to content

[Security] New Password Policy listener #49821

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public function getConfigTreeBuilder(): TreeBuilder
$this->addFirewallsSection($rootNode, $this->factories);
$this->addAccessControlSection($rootNode);
$this->addRoleHierarchySection($rootNode);
$this->addPasswordPolicySection($rootNode);

return $tb;
}
Expand Down Expand Up @@ -461,6 +462,21 @@ private function addPasswordHashersSection(ArrayNodeDefinition $rootNode): void
->end();
}

private function addPasswordPolicySection(ArrayNodeDefinition $rootNode): void
{
$rootNode
->fixXmlConfig('password_policy')
->children()
->arrayNode('password_policies')
->treatNullLike([])
->treatFalseLike([])
->treatTrueLike([])
->prototype('scalar')->end()
->end()
->end()
;
}

private function getAccessDecisionStrategies(): array
{
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ public function load(array $configs, ContainerBuilder $container)
$container->getDefinition('security.access_listener')->setArgument(3, false);
$container->getDefinition('security.authorization_checker')->setArgument(2, false);
$container->getDefinition('security.authorization_checker')->setArgument(3, false);

if (!$config['password_policies']) {
$container->removeDefinition('security.password_policy_listener');
} else {
$policyServices = array_map(static fn (string $id) => new Reference($id), $config['password_policies']);
$container->getDefinition('security.password_policy_listener')->setArgument(0, $policyServices);
}
}

private function createStrategyDefinition(string $strategy, bool $allowIfAllAbstainDecisions, bool $allowIfEqualGrantedDeniedDecisions): Definition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
use Symfony\Component\Security\Http\Controller\SecurityTokenValueResolver;
use Symfony\Component\Security\Http\Controller\UserValueResolver;
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
use Symfony\Component\Security\Http\EventListener\PasswordPolicyListener;
use Symfony\Component\Security\Http\Firewall;
use Symfony\Component\Security\Http\FirewallMapInterface;
use Symfony\Component\Security\Http\HttpUtils;
Expand Down Expand Up @@ -298,5 +299,11 @@
->set('cache.security_is_granted_attribute_expression_language')
->parent('cache.system')
->tag('cache.pool')

->set('security.password_policy_listener', PasswordPolicyListener::class)
->args([
[],
])
->tag('kernel.event_subscriber')
;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\Tests\Functional;

use Symfony\Component\HttpFoundation\JsonResponse;

class PasswordPolicyTest extends AbstractWebTestCase
{
/**
* @dataProvider providePassword
*/
public function testLoginFailBecauseThePasswordIsBlacklisted(string $password, string $expectedMessage)
{
// Given
$client = $this->createClient(['test_case' => 'PasswordPolicy', 'root_config' => 'config.yml']);

// When
$client->request('POST', '/chk', [], [], ['CONTENT_TYPE' => 'application/json'], '{"user": {"login": "dunglas", "password": "'.$password.'"}}');
$response = $client->getResponse();

// Then
$this->assertInstanceOf(JsonResponse::class, $response);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame(['error' => $expectedMessage], json_decode($response->getContent(), true));
}

public static function providePassword(): iterable
{
yield ['foo', 'The password does not fulfill the password policy.'];
yield ['short?', 'The password does not fulfill the password policy.'];
yield ['Good password?', 'The password does not fulfill the password policy.'];

// The following password fulfills the password policy, but is not valid.
yield ['Is it a v4l1d pasw0rd?', 'Invalid credentials.'];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Bundle\SecurityBundle\Tests\Functional\app\PasswordPolicy;

use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PasswordPolicyInterface;

final class BlocklistPasswordPolicy implements PasswordPolicyInterface
{
public function verify(#[\SensitiveParameter] string $plaintextPassword): bool
{
return 'foo' !== $plaintextPassword;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

return [
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle(),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
imports:
- { resource: ./../config/framework.yml }

framework:
http_method_override: false
serializer: ~

security:
password_policies:
- 'policy.blocklist'
- 'policy.constraint_password'

password_hashers:
Symfony\Component\Security\Core\User\InMemoryUser: plaintext

providers:
in_memory:
memory:
users:
dunglas: { password: foo, roles: [ROLE_USER] }

firewalls:
main:
pattern: ^/
json_login:
check_path: /chk
username_path: user.login
password_path: user.password

access_control:
- { path: ^/foo, roles: ROLE_USER }


services:
policy.constraint_password.length:
class: Symfony\Component\Validator\Constraints\Length
arguments:
- min: 8
policy.constraint_password.strength:
class: Symfony\Component\Validator\Constraints\PasswordStrength
arguments:
- minScore: 2

policy.constraint_password:
class: Symfony\Component\Security\Http\Authenticator\Passport\Policy\PasswordConstraintPolicy
arguments:
- '@validator'
- ['@policy.constraint_password.length', '@policy.constraint_password.strength']

policy.blocklist:
class: Symfony\Bundle\SecurityBundle\Tests\Functional\app\PasswordPolicy\BlocklistPasswordPolicy
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
login_check:
path: /chk
defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLoginBundle\Controller\TestController::loginCheckAction }
2 changes: 1 addition & 1 deletion src/Symfony/Component/Security/Core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ CHANGELOG
* Remove all classes in the `Core\Encoder\` sub-namespace, use the `PasswordHasher` component instead
* Remove methods `getPassword()` and `getSalt()` from `UserInterface`, use `PasswordAuthenticatedUserInterface`
or `LegacyPasswordAuthenticatedUserInterface` instead
* `AccessDecisionManager` requires the strategy to be passed as in instance of `AccessDecisionStrategyInterface`
* `AccessDecisionManager` requires the strategy to be passed as in instance of `AccessDecisionStrategyInterface`

5.4.21
------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Core\Exception;

final class PasswordPolicyException extends AuthenticationException
{
public function getMessageKey(): string
{
return 'The password does not fulfill the password policy.';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Http\Authenticator\Passport\Policy;

use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class PasswordConstraintPolicy implements PasswordPolicyInterface
{
/**
* @param array<Constraint> $constraints
*/
public function __construct(
private readonly ValidatorInterface $validator,
private readonly array $constraints = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing comma at the end

) {
}

/**
* @throws AuthenticationException
*/
public function verify(#[\SensitiveParameter] string $plaintextPassword): bool
{
$errors = $this->validator->validate($plaintextPassword, $this->constraints);

return !$errors->count();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Http\Authenticator\Passport\Policy;

use Symfony\Component\Security\Core\Exception\AuthenticationException;

interface PasswordPolicyInterface
{
/**
* @throws AuthenticationException
*/
public function verify(#[\SensitiveParameter] string $plaintextPassword): bool;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we stipulate that, instead of returning a bool, it must either throw a PasswordPolicyException or return void?

}
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
6.4
---

* Add Password Policy to allow verifying the password using a custom security policy (e.g. password not compromised, change required by IT service, etc.)
* `UserValueResolver` no longer implements `ArgumentValueResolverInterface`

6.3
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Security\Http\EventListener;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\PasswordPolicyException;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PasswordPolicyInterface;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;

final class PasswordPolicyListener implements EventSubscriberInterface
{
/**
* @param PasswordPolicyInterface[] $policies
*/
public function __construct(private readonly array $policies)
{
}

public function checkPassport(CheckPassportEvent $event): void
{
$passport = $event->getPassport();
if (!$passport->hasBadge(PasswordCredentials::class)) {
return;
}

$badge = $passport->getBadge(PasswordCredentials::class);
if ($badge->isResolved()) {
return;
}

$plaintextPassword = $badge->getPassword();
foreach ($this->policies as $policy) {
if (!$policy->verify($plaintextPassword)) {
throw new PasswordPolicyException();
}
}
}

public static function getSubscribedEvents(): array
{
return [
CheckPassportEvent::class => ['checkPassport', 512],
];
}
}
Loading