Skip to content

Commit 1b69f77

Browse files
committed
Password Policy
1 parent 398830c commit 1b69f77

File tree

14 files changed

+319
-0
lines changed

14 files changed

+319
-0
lines changed

src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public function getConfigTreeBuilder(): TreeBuilder
9898
$this->addFirewallsSection($rootNode, $this->factories);
9999
$this->addAccessControlSection($rootNode);
100100
$this->addRoleHierarchySection($rootNode);
101+
$this->addPasswordPolicySection($rootNode);
101102

102103
return $tb;
103104
}
@@ -461,6 +462,21 @@ private function addPasswordHashersSection(ArrayNodeDefinition $rootNode): void
461462
->end();
462463
}
463464

465+
private function addPasswordPolicySection(ArrayNodeDefinition $rootNode): void
466+
{
467+
$rootNode
468+
->fixXmlConfig('password_policy')
469+
->children()
470+
->arrayNode('password_policies')
471+
->treatNullLike([])
472+
->treatFalseLike([])
473+
->treatTrueLike([])
474+
->prototype('scalar')->end()
475+
->end()
476+
->end()
477+
;
478+
}
479+
464480
private function getAccessDecisionStrategies(): array
465481
{
466482
return [

src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ public function load(array $configs, ContainerBuilder $container)
190190
$container->getDefinition('security.access_listener')->setArgument(3, false);
191191
$container->getDefinition('security.authorization_checker')->setArgument(2, false);
192192
$container->getDefinition('security.authorization_checker')->setArgument(3, false);
193+
194+
if (!$config['password_policies']) {
195+
$container->removeDefinition('security.password_policy_listener');
196+
} else {
197+
$policyServices = array_map(static fn (string $id) => new Definition($id), $config['password_policies']);
198+
$container->getDefinition('security.password_policy_listener')->setArgument(0, $policyServices);
199+
}
193200
}
194201

195202
/**

src/Symfony/Bundle/SecurityBundle/Resources/config/security.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Symfony\Component\Security\Http\Controller\SecurityTokenValueResolver;
4646
use Symfony\Component\Security\Http\Controller\UserValueResolver;
4747
use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener;
48+
use Symfony\Component\Security\Http\EventListener\PasswordPolicyListener;
4849
use Symfony\Component\Security\Http\Firewall;
4950
use Symfony\Component\Security\Http\FirewallMapInterface;
5051
use Symfony\Component\Security\Http\HttpUtils;
@@ -298,5 +299,11 @@
298299
->set('cache.security_is_granted_attribute_expression_language')
299300
->parent('cache.system')
300301
->tag('cache.pool')
302+
303+
->set('security.password_policy_listener', PasswordPolicyListener::class)
304+
->args([
305+
[],
306+
])
307+
->tag('kernel.event_subscriber')
301308
;
302309
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
13+
14+
use Symfony\Component\HttpFoundation\JsonResponse;
15+
16+
class PasswordPolicyTest extends AbstractWebTestCase
17+
{
18+
public function testLoginFailBecauseThePasswordIsBlacklisted()
19+
{
20+
// Given
21+
$client = $this->createClient(['test_case' => 'PasswordPolicy', 'root_config' => 'config.yml']);
22+
23+
// When
24+
$client->request('POST', '/chk', [], [], ['CONTENT_TYPE' => 'application/json'], '{"user": {"login": "dunglas", "password": "foo"}}');
25+
$response = $client->getResponse();
26+
27+
// Then
28+
$this->assertInstanceOf(JsonResponse::class, $response);
29+
$this->assertSame(401, $response->getStatusCode());
30+
$this->assertSame(['error' => 'The password shall be reset.'], json_decode($response->getContent(), true));
31+
}
32+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Tests\Functional\app\PasswordPolicy;
13+
14+
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PolicyInterface;
15+
16+
final class BlocklistPasswordPolicy implements PolicyInterface
17+
{
18+
public function verify(#[\SensitiveParameter] string $plaintextPassword): void
19+
{
20+
if ('foo' === $plaintextPassword) {
21+
throw new PasswordResetRequiredException();
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\Tests\Functional\app\PasswordPolicy;
13+
14+
use Symfony\Component\Security\Core\Exception\PolicyException;
15+
16+
final class PasswordResetRequiredException extends PolicyException
17+
{
18+
public function getMessageKey(): string
19+
{
20+
return 'The password shall be reset.';
21+
}
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
return [
13+
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
14+
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
15+
new Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\TestBundle(),
16+
];
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
imports:
2+
- { resource: ./../config/framework.yml }
3+
4+
framework:
5+
http_method_override: false
6+
serializer: ~
7+
8+
security:
9+
password_policies:
10+
- 'Symfony\Bundle\SecurityBundle\Tests\Functional\app\PasswordPolicy\BlocklistPasswordPolicy'
11+
12+
password_hashers:
13+
Symfony\Component\Security\Core\User\InMemoryUser: plaintext
14+
15+
providers:
16+
in_memory:
17+
memory:
18+
users:
19+
dunglas: { password: foo, roles: [ROLE_USER] }
20+
21+
firewalls:
22+
main:
23+
pattern: ^/
24+
json_login:
25+
check_path: /chk
26+
username_path: user.login
27+
password_path: user.password
28+
29+
access_control:
30+
- { path: ^/foo, roles: ROLE_USER }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
login_check:
2+
path: /chk
3+
defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLoginBundle\Controller\TestController::loginCheckAction }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Core\Exception;
13+
14+
class PolicyException extends AuthenticationException
15+
{
16+
public function getMessageKey(): string
17+
{
18+
return 'The password does not fulfill the password policy.';
19+
}
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Authenticator\Passport\Policy;
13+
14+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
15+
16+
interface PolicyInterface
17+
{
18+
/**
19+
* @throws AuthenticationException
20+
*/
21+
public function verify(#[\SensitiveParameter] string $plaintextPassword): void;
22+
}

src/Symfony/Component/Security/Http/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
CHANGELOG
22
=========
33

4+
6.4
5+
---
6+
* Add Password Policy to allow verifying the password using a custom security policy (e.g. password not compromised, change required by IT service, etc.)
7+
48
6.3
59
---
610

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
16+
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PolicyInterface;
17+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
18+
19+
final class PasswordPolicyListener implements EventSubscriberInterface
20+
{
21+
/**
22+
* @param PolicyInterface[] $policies
23+
*/
24+
public function __construct(private readonly array $policies)
25+
{
26+
}
27+
28+
public function checkPassport(CheckPassportEvent $event): void
29+
{
30+
$passport = $event->getPassport();
31+
if (!$passport->hasBadge(PasswordCredentials::class)) {
32+
return;
33+
}
34+
35+
$badge = $passport->getBadge(PasswordCredentials::class);
36+
if ($badge->isResolved()) {
37+
return;
38+
}
39+
40+
$plaintextPassword = $badge->getPassword();
41+
foreach ($this->policies as $policy) {
42+
$policy->verify($plaintextPassword);
43+
}
44+
}
45+
46+
public static function getSubscribedEvents(): array
47+
{
48+
return [
49+
CheckPassportEvent::class => ['checkPassport', 512],
50+
];
51+
}
52+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Http\Tests\EventListener;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\Exception\PolicyException;
16+
use Symfony\Component\Security\Core\User\InMemoryUser;
17+
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
18+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
19+
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
20+
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
21+
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PolicyInterface;
22+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
23+
use Symfony\Component\Security\Http\EventListener\PasswordPolicyListener;
24+
25+
class PasswordPolicyListenerTest extends TestCase
26+
{
27+
/**
28+
* @param array<PolicyInterface> $policies
29+
*
30+
* @dataProvider providePassport
31+
*/
32+
public function testPasswordIsNotAcceptable(Passport $passport, array $policies, string $expectedMessage)
33+
{
34+
// Given
35+
$event = new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport);
36+
$listener = new PasswordPolicyListener($policies);
37+
38+
try {
39+
// When
40+
$listener->checkPassport($event);
41+
$this->fail('Expected exception to be thrown');
42+
} catch (PolicyException $e) {
43+
// Then
44+
$this->assertSame($expectedMessage, $e->getMessageKey());
45+
}
46+
}
47+
48+
public static function providePassport(): iterable
49+
{
50+
yield [
51+
new Passport(
52+
new UserBadge('test', fn () => new InMemoryUser('test', 'qwerty')),
53+
new PasswordCredentials('qwerty')
54+
),
55+
[new class() implements PolicyInterface {
56+
public function verify(string $plaintextPassword): void
57+
{
58+
throw new PolicyException();
59+
}
60+
}],
61+
'The password does not fulfill the password policy.',
62+
];
63+
}
64+
}

0 commit comments

Comments
 (0)