Skip to content

Commit f730b43

Browse files
committed
Password Policy
1 parent 8c637a5 commit f730b43

File tree

8 files changed

+1231
-0
lines changed

8 files changed

+1231
-0
lines changed
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+
final class CompromisedPasswordPolicyException extends PolicyException
15+
{
16+
public function getMessageKey(): string
17+
{
18+
return 'Compromised password.';
19+
}
20+
}
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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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\CompromisedPasswordPolicyException;
15+
use Symfony\Contracts\HttpClient\HttpClientInterface;
16+
17+
final class NotCompromisedPasswordPolicy implements PolicyInterface
18+
{
19+
private const DEFAULT_API_ENDPOINT = 'https://api.pwnedpasswords.com/range/%s';
20+
21+
private readonly string $endpoint;
22+
23+
public function __construct(
24+
private readonly HttpClientInterface $httpClient,
25+
private readonly bool $skipOnError = false,
26+
string $endpoint = null
27+
) {
28+
$this->endpoint = $endpoint ?? self::DEFAULT_API_ENDPOINT;
29+
}
30+
31+
public function verify(#[\SensitiveParameter] string $plaintextPassword): void
32+
{
33+
$hash = mb_strtoupper(sha1($plaintextPassword));
34+
$hashPrefix = mb_substr($hash, 0, 5);
35+
$url = sprintf($this->endpoint, $hashPrefix);
36+
37+
try {
38+
$result = $this->httpClient->request('GET', $url, [
39+
'headers' => [
40+
'Add-Padding' => 'true',
41+
],
42+
])->getContent();
43+
} catch (\Throwable $exception) {
44+
if ($this->skipOnError) {
45+
return;
46+
}
47+
48+
throw $exception;
49+
}
50+
51+
foreach (explode("\r\n", $result) as $line) {
52+
if (!str_contains($line, ':')) {
53+
continue;
54+
}
55+
56+
[$hashSuffix, $count] = explode(':', $line);
57+
58+
if ($hashPrefix.$hashSuffix === $hash && (int) $count >= 1) {
59+
throw new CompromisedPasswordPolicyException();
60+
}
61+
}
62+
}
63+
}
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
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

711
* Add `RememberMeBadge` to `JsonLoginAuthenticator` and enable reading parameter in JSON request body
12+
* Add `ConstraintBadge` to allow the validation of the authentication material (e.g. username, password) using constraints
813
* Add argument `$exceptionCode` to `#[IsGranted]`
914
* Deprecate passing a secret as the 2nd argument to the constructor of `Symfony\Component\Security\Http\RememberMe\PersistentRememberMeHandler`
1015
* Add `OidcUserInfoTokenHandler` and `OidcTokenHandler` with OIDC support for `AccessTokenAuthenticator`
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 array<PolicyInterface> $policies
23+
*/
24+
public function __construct(
25+
private readonly array $policies = [],
26+
) {
27+
}
28+
29+
public function checkPassport(CheckPassportEvent $event): void
30+
{
31+
$passport = $event->getPassport();
32+
if (!$passport->hasBadge(PasswordCredentials::class)) {
33+
return;
34+
}
35+
36+
$badge = $passport->getBadge(PasswordCredentials::class);
37+
if ($badge->isResolved()) {
38+
return;
39+
}
40+
41+
foreach ($this->policies as $policy) {
42+
$policy->verify($badge->getPassword());
43+
}
44+
}
45+
46+
public static function getSubscribedEvents(): array
47+
{
48+
return [
49+
CheckPassportEvent::class => ['checkPassport', 512],
50+
];
51+
}
52+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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\PasswordPolicy;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\MockHttpClient;
16+
use Symfony\Component\HttpClient\Response\MockResponse;
17+
use Symfony\Component\Security\Core\Exception\PolicyException;
18+
use Symfony\Component\Security\Core\User\InMemoryUser;
19+
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
20+
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
21+
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
22+
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
23+
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\NotCompromisedPasswordPolicy;
24+
use Symfony\Component\Security\Http\Authenticator\Passport\Policy\PolicyInterface;
25+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
26+
use Symfony\Component\Security\Http\EventListener\PasswordPolicyListener;
27+
28+
class PasswordPolicyListenerTest extends TestCase
29+
{
30+
/**
31+
* @param array<PolicyInterface> $policy
32+
*
33+
* @dataProvider providePassport
34+
*/
35+
public function testPasswordIsNotAcceptable(Passport $passport, array $policy, string $expectedMessage)
36+
{
37+
// Given
38+
$event = new CheckPassportEvent($this->createMock(AuthenticatorInterface::class), $passport);
39+
$listener = new PasswordPolicyListener($policy);
40+
41+
try {
42+
// When
43+
$listener->checkPassport($event);
44+
$this->fail('Expected exception to be thrown');
45+
} catch (PolicyException $e) {
46+
// Then
47+
$this->assertSame($expectedMessage, $e->getMessageKey());
48+
}
49+
}
50+
51+
public static function providePassport(): iterable
52+
{
53+
// We use a real response from haveibeenpwned.com with the password "qwerty" to test the NotCompromisedPasswordPolicy
54+
$data = preg_replace('~\R~u', "\r\n", file_get_contents(__DIR__.'/qwerty.txt'));
55+
$httpClient = new MockHttpClient();
56+
$httpClient->setResponseFactory(fn () => new MockResponse($data, ['http_code' => 200]));
57+
yield [
58+
new Passport(
59+
new UserBadge('test', fn () => new InMemoryUser('test', 'qwerty')),
60+
new PasswordCredentials('qwerty')
61+
),
62+
[new NotCompromisedPasswordPolicy($httpClient)],
63+
'Compromised password.',
64+
];
65+
66+
yield [
67+
new Passport(
68+
new UserBadge('test', fn () => new InMemoryUser('test', 'qwerty')),
69+
new PasswordCredentials('qwerty')
70+
),
71+
[new class() implements PolicyInterface {
72+
public function verify(string $plaintextPassword): void
73+
{
74+
throw new PolicyException();
75+
}
76+
}],
77+
'The password does not fulfill the password policy.',
78+
];
79+
80+
yield [
81+
new Passport(
82+
new UserBadge('test', fn () => new InMemoryUser('test', 'qwerty')),
83+
new PasswordCredentials('too short pwd')
84+
),
85+
[new class() implements PolicyInterface {
86+
public function verify(string $plaintextPassword): void
87+
{
88+
if (mb_strlen($plaintextPassword) < 15) {
89+
throw new PolicyException();
90+
}
91+
}
92+
}],
93+
'The password does not fulfill the password policy.',
94+
];
95+
}
96+
}

0 commit comments

Comments
 (0)