Skip to content
This repository was archived by the owner on May 31, 2024. It is now read-only.

Commit 1b6e9fb

Browse files
[Security] Add NativePasswordEncoder
1 parent 38aba37 commit 1b6e9fb

File tree

5 files changed

+209
-18
lines changed

5 files changed

+209
-18
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ CHANGELOG
44
4.3.0
55
-----
66

7+
* Added methods `__serialize` and `__unserialize` to the `TokenInterface`
8+
* Added `SodiumPasswordEncoder` and `NativePasswordEncoder`
79
* The `Role` and `SwitchUserRole` classes are deprecated and will be removed in 5.0. Use strings for roles
810
instead.
911
* The `getReachableRoles()` method of the `RoleHierarchyInterface` is deprecated and will be removed in 5.0.
@@ -19,8 +21,7 @@ CHANGELOG
1921
* Dispatch `AuthenticationFailureEvent` on `security.authentication.failure`
2022
* Dispatch `InteractiveLoginEvent` on `security.interactive_login`
2123
* Dispatch `SwitchUserEvent` on `security.switch_user`
22-
* deprecated `Argon2iPasswordEncoder`, use `SodiumPasswordEncoder` instead
23-
* Added methods `__serialize` and `__unserialize` to the `TokenInterface`
24+
* Deprecated `Argon2iPasswordEncoder`, use `SodiumPasswordEncoder`
2425

2526
4.2.0
2627
-----

Core/Encoder/EncoderFactory.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ private function createEncoder(array $config)
8484

8585
private function getEncoderConfigFromAlgorithm($config)
8686
{
87+
if ('auto' === $config['algorithm']) {
88+
$config['algorithm'] = SodiumPasswordEncoder::isSupported() ? 'sodium' : 'native';
89+
}
90+
8791
switch ($config['algorithm']) {
8892
case 'plaintext':
8993
return [
@@ -108,10 +112,23 @@ private function getEncoderConfigFromAlgorithm($config)
108112
'arguments' => [$config['cost']],
109113
];
110114

115+
case 'native':
116+
return [
117+
'class' => NativePasswordEncoder::class,
118+
'arguments' => [
119+
$config['time_cost'] ?? null,
120+
(($config['memory_cost'] ?? 0) << 10) ?: null,
121+
$config['cost'] ?? null,
122+
],
123+
];
124+
111125
case 'sodium':
112126
return [
113127
'class' => SodiumPasswordEncoder::class,
114-
'arguments' => [],
128+
'arguments' => [
129+
$config['time_cost'] ?? null,
130+
(($config['memory_cost'] ?? 0) << 10) ?: null,
131+
],
115132
];
116133

117134
/* @deprecated since Symfony 4.3 */
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Encoder;
13+
14+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
15+
16+
/**
17+
* Hashes passwords using password_hash().
18+
*
19+
* @author Elnur Abdurrakhimov <[email protected]>
20+
* @author Terje Bråten <[email protected]>
21+
* @author Nicolas Grekas <[email protected]>
22+
*/
23+
final class NativePasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface
24+
{
25+
private const MAX_PASSWORD_LENGTH = 4096;
26+
27+
private $algo;
28+
private $options;
29+
30+
public function __construct(int $opsLimit = null, int $memLimit = null, int $cost = null)
31+
{
32+
$cost = $cost ?? 13;
33+
$opsLimit = $opsLimit ?? max(6, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE : 6);
34+
$memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024);
35+
36+
if (2 > $opsLimit) {
37+
throw new \InvalidArgumentException('$opsLimit must be 2 or greater.');
38+
}
39+
40+
if (10 * 1024 > $memLimit) {
41+
throw new \InvalidArgumentException('$memLimit must be 10k or greater.');
42+
}
43+
44+
if ($cost < 4 || 31 < $cost) {
45+
throw new \InvalidArgumentException('$cost must be in the range of 4-31.');
46+
}
47+
48+
$this->algo = \defined('PASSWORD_ARGON2I') ? max(PASSWORD_DEFAULT, \defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_ARGON2I) : PASSWORD_DEFAULT;
49+
$this->options = [
50+
'cost' => $cost,
51+
'time_cost' => $opsLimit,
52+
'memory_cost' => $memLimit >> 10,
53+
'threads' => 1,
54+
];
55+
}
56+
57+
/**
58+
* {@inheritdoc}
59+
*/
60+
public function encodePassword($raw, $salt)
61+
{
62+
if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) {
63+
throw new BadCredentialsException('Invalid password.');
64+
}
65+
66+
// Ignore $salt, the auto-generated one is always the best
67+
68+
$encoded = password_hash($raw, $this->algo, $this->options);
69+
70+
if (72 < \strlen($raw) && 0 === strpos($encoded, '$2')) {
71+
// BCrypt encodes only the first 72 chars
72+
throw new BadCredentialsException('Invalid password.');
73+
}
74+
75+
return $encoded;
76+
}
77+
78+
/**
79+
* {@inheritdoc}
80+
*/
81+
public function isPasswordValid($encoded, $raw, $salt)
82+
{
83+
if (72 < \strlen($raw) && 0 === strpos($encoded, '$2')) {
84+
// BCrypt encodes only the first 72 chars
85+
return false;
86+
}
87+
88+
return \strlen($raw) <= self::MAX_PASSWORD_LENGTH && password_verify($raw, $encoded);
89+
}
90+
}

Core/Encoder/SodiumPasswordEncoder.php

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,32 @@
2020
* @author Robin Chalas <[email protected]>
2121
* @author Zan Baldwin <[email protected]>
2222
* @author Dominik Müller <[email protected]>
23-
*
24-
* @final
2523
*/
26-
class SodiumPasswordEncoder extends BasePasswordEncoder implements SelfSaltingEncoderInterface
24+
final class SodiumPasswordEncoder implements PasswordEncoderInterface, SelfSaltingEncoderInterface
2725
{
26+
private const MAX_PASSWORD_LENGTH = 4096;
27+
28+
private $opsLimit;
29+
private $memLimit;
30+
31+
public function __construct(int $opsLimit = null, int $memLimit = null)
32+
{
33+
if (!self::isSupported()) {
34+
throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.');
35+
}
36+
37+
$this->opsLimit = $opsLimit ?? max(6, \defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE : 6);
38+
$this->memLimit = $memLimit ?? max(64 * 1024 * 1024, \defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 2014);
39+
40+
if (2 > $this->opsLimit) {
41+
throw new \InvalidArgumentException('$opsLimit must be 2 or greater.');
42+
}
43+
44+
if (10 * 1024 > $this->memLimit) {
45+
throw new \InvalidArgumentException('$memLimit must be 10k or greater.');
46+
}
47+
}
48+
2849
public static function isSupported(): bool
2950
{
3051
if (\class_exists('ParagonIE_Sodium_Compat') && \method_exists('ParagonIE_Sodium_Compat', 'crypto_pwhash_is_available')) {
@@ -39,24 +60,16 @@ public static function isSupported(): bool
3960
*/
4061
public function encodePassword($raw, $salt)
4162
{
42-
if ($this->isPasswordTooLong($raw)) {
63+
if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) {
4364
throw new BadCredentialsException('Invalid password.');
4465
}
4566

4667
if (\function_exists('sodium_crypto_pwhash_str')) {
47-
return \sodium_crypto_pwhash_str(
48-
$raw,
49-
\SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
50-
\SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
51-
);
68+
return \sodium_crypto_pwhash_str($raw, $this->opsLimit, $this->memLimit);
5269
}
5370

5471
if (\extension_loaded('libsodium')) {
55-
return \Sodium\crypto_pwhash_str(
56-
$raw,
57-
\Sodium\CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
58-
\Sodium\CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
59-
);
72+
return \Sodium\crypto_pwhash_str($raw, $this->opsLimit, $this->memLimit);
6073
}
6174

6275
throw new LogicException('Libsodium is not available. You should either install the sodium extension, upgrade to PHP 7.2+ or use a different encoder.');
@@ -67,7 +80,7 @@ public function encodePassword($raw, $salt)
6780
*/
6881
public function isPasswordValid($encoded, $raw, $salt)
6982
{
70-
if ($this->isPasswordTooLong($raw)) {
83+
if (\strlen($raw) > self::MAX_PASSWORD_LENGTH) {
7184
return false;
7285
}
7386

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Tests\Encoder;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder;
16+
17+
/**
18+
* @author Elnur Abdurrakhimov <[email protected]>
19+
*/
20+
class NativePasswordEncoderTest extends TestCase
21+
{
22+
/**
23+
* @expectedException \InvalidArgumentException
24+
*/
25+
public function testCostBelowRange()
26+
{
27+
new NativePasswordEncoder(null, null, 3);
28+
}
29+
30+
/**
31+
* @expectedException \InvalidArgumentException
32+
*/
33+
public function testCostAboveRange()
34+
{
35+
new NativePasswordEncoder(null, null, 32);
36+
}
37+
38+
/**
39+
* @dataProvider validRangeData
40+
*/
41+
public function testCostInRange($cost)
42+
{
43+
$this->assertInstanceOf(NativePasswordEncoder::class, new NativePasswordEncoder(null, null, $cost));
44+
}
45+
46+
public function validRangeData()
47+
{
48+
$costs = range(4, 31);
49+
array_walk($costs, function (&$cost) { $cost = [$cost]; });
50+
51+
return $costs;
52+
}
53+
54+
public function testValidation()
55+
{
56+
$encoder = new NativePasswordEncoder();
57+
$result = $encoder->encodePassword('password', null);
58+
$this->assertTrue($encoder->isPasswordValid($result, 'password', null));
59+
$this->assertFalse($encoder->isPasswordValid($result, 'anotherPassword', null));
60+
}
61+
62+
public function testCheckPasswordLength()
63+
{
64+
$encoder = new NativePasswordEncoder(null, null, 4);
65+
$result = password_hash(str_repeat('a', 72), PASSWORD_BCRYPT, ['cost' => 4]);
66+
67+
$this->assertFalse($encoder->isPasswordValid($result, str_repeat('a', 73), 'salt'));
68+
$this->assertTrue($encoder->isPasswordValid($result, str_repeat('a', 72), 'salt'));
69+
}
70+
}

0 commit comments

Comments
 (0)