diff --git a/src/Auth/Hash.php b/src/Auth/Hash.php index 82b8d9b..b86481a 100644 --- a/src/Auth/Hash.php +++ b/src/Auth/Hash.php @@ -16,13 +16,28 @@ abstract class Hash * @param mixed $value The value to set for the option * @return self */ - protected function setOption(string $key, mixed $value): self + public function setOption(string $key, mixed $value): self { $this->options[$key] = $value; return $this; } + /** + * Set multiple hashing options at once + * + * @param array $options Array of options to set + * @return self + */ + public function setOptions(array $options): self + { + foreach ($options as $key => $value) { + $this->setOption($key, $value); + } + + return $this; + } + /** * Get a specific option value * @@ -30,7 +45,7 @@ protected function setOption(string $key, mixed $value): self * @param mixed $default Default value if option doesn't exist * @return mixed The option value or default if not found */ - protected function getOption(string $key, mixed $default = null): mixed + public function getOption(string $key, mixed $default = null): mixed { return $this->options[$key] ?? $default; } @@ -61,4 +76,11 @@ abstract public function hash(string $value): string; * @return bool */ abstract public function verify(string $value, string $hash): bool; + + /** + * Get the name of the hash algorithm + * + * @return string + */ + abstract public function getName(): string; } diff --git a/src/Auth/Hashes/Argon2.php b/src/Auth/Hashes/Argon2.php index 09b4ed4..8e3f435 100644 --- a/src/Auth/Hashes/Argon2.php +++ b/src/Auth/Hashes/Argon2.php @@ -11,8 +11,9 @@ class Argon2 extends Hash */ public function __construct() { - $this->setOption('memory_cost', 65536); - $this->setOption('time_cost', 4); + $this->setOption('type', $this->getName()); + $this->setOption('memoryCost', 65536); + $this->setOption('timeCost', 4); $this->setOption('threads', 3); } @@ -42,11 +43,7 @@ public function verify(string $value, string $hash): bool */ public function setMemoryCost(int $cost): self { - if ($cost < PASSWORD_ARGON2_DEFAULT_MEMORY_COST) { - throw new \InvalidArgumentException('Memory cost must be >= '.PASSWORD_ARGON2_DEFAULT_MEMORY_COST.' KiB'); - } - - $this->setOption('memory_cost', $cost); + $this->setOption('memoryCost', $cost); return $this; } @@ -61,11 +58,7 @@ public function setMemoryCost(int $cost): self */ public function setTimeCost(int $cost): self { - if ($cost < PASSWORD_ARGON2_DEFAULT_TIME_COST) { - throw new \InvalidArgumentException('Time cost must be >= '.PASSWORD_ARGON2_DEFAULT_TIME_COST); - } - - $this->setOption('time_cost', $cost); + $this->setOption('timeCost', $cost); return $this; } @@ -80,12 +73,16 @@ public function setTimeCost(int $cost): self */ public function setThreads(int $threads): self { - if ($threads < PASSWORD_ARGON2_DEFAULT_THREADS) { - throw new \InvalidArgumentException('Threads must be >= '.PASSWORD_ARGON2_DEFAULT_THREADS); - } - $this->setOption('threads', $threads); return $this; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'argon2'; + } } diff --git a/src/Auth/Hashes/Bcrypt.php b/src/Auth/Hashes/Bcrypt.php index 3b55fab..b7fc6be 100644 --- a/src/Auth/Hashes/Bcrypt.php +++ b/src/Auth/Hashes/Bcrypt.php @@ -11,6 +11,7 @@ class Bcrypt extends Hash */ public function __construct() { + $this->setOption('type', $this->getName()); $this->setOption('cost', 8); } @@ -48,4 +49,12 @@ public function setCost(int $cost): self return $this; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'bcrypt'; + } } diff --git a/src/Auth/Hashes/MD5.php b/src/Auth/Hashes/MD5.php index 301f337..2960e41 100644 --- a/src/Auth/Hashes/MD5.php +++ b/src/Auth/Hashes/MD5.php @@ -6,6 +6,14 @@ class MD5 extends Hash { + /** + * Constructor + */ + public function __construct() + { + $this->setOption('type', $this->getName()); + } + /** * {@inheritdoc} */ @@ -21,4 +29,12 @@ public function verify(string $value, string $hash): bool { return $this->hash($value) === $hash; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'md5'; + } } diff --git a/src/Auth/Hashes/PHPass.php b/src/Auth/Hashes/PHPass.php index b70d7b1..fa95790 100644 --- a/src/Auth/Hashes/PHPass.php +++ b/src/Auth/Hashes/PHPass.php @@ -21,6 +21,7 @@ public function __construct() $randomState .= getmypid(); } + $this->setOption('type', $this->getName()); $this->setOption('iteration_count_log2', 8); $this->setOption('portable_hashes', false); $this->setOption('random_state', $randomState); @@ -253,4 +254,12 @@ public function setPortableHashes(bool $portable): PHPass return $this; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'phpass'; + } } diff --git a/src/Auth/Hashes/Plaintext.php b/src/Auth/Hashes/Plaintext.php index 531bebe..36cbec7 100644 --- a/src/Auth/Hashes/Plaintext.php +++ b/src/Auth/Hashes/Plaintext.php @@ -6,6 +6,14 @@ class Plaintext extends Hash { + /** + * Constructor + */ + public function __construct() + { + $this->setOption('type', $this->getName()); + } + /** * {@inheritdoc} */ @@ -21,4 +29,12 @@ public function verify(string $value, string $hash): bool { return $this->hash($value) === $hash; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'plaintext'; + } } diff --git a/src/Auth/Hashes/Scrypt.php b/src/Auth/Hashes/Scrypt.php index 901fc0b..33252b9 100644 --- a/src/Auth/Hashes/Scrypt.php +++ b/src/Auth/Hashes/Scrypt.php @@ -11,6 +11,7 @@ class Scrypt extends Hash */ public function __construct() { + $this->setOption('type', $this->getName()); $this->setOption('costCpu', 8); $this->setOption('costMemory', 14); $this->setOption('costParallel', 1); @@ -139,4 +140,12 @@ public function setSalt(string $salt): self return $this; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'scrypt'; + } } diff --git a/src/Auth/Hashes/ScryptModified.php b/src/Auth/Hashes/ScryptModified.php index db4a9dd..45938d4 100644 --- a/src/Auth/Hashes/ScryptModified.php +++ b/src/Auth/Hashes/ScryptModified.php @@ -16,6 +16,8 @@ public function __construct() $saltSeparator = random_bytes(16); $signerKey = random_bytes(32); + $this->setOption('type', $this->getName()); + // Set default options with secure random values $this->setOption('salt', base64_encode($salt)); $this->setOption('saltSeparator', base64_encode($saltSeparator)); @@ -164,4 +166,12 @@ public function setSignerKey(string $key): self return $this; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'scryptMod'; + } } diff --git a/src/Auth/Hashes/Sha.php b/src/Auth/Hashes/Sha.php index 52d01c8..90db2f6 100644 --- a/src/Auth/Hashes/Sha.php +++ b/src/Auth/Hashes/Sha.php @@ -29,7 +29,7 @@ class Sha extends Hash */ public function __construct() { - $this->setOption('version', 'sha3-512'); + $this->setOption('version', 'sha256'); } /** @@ -86,4 +86,12 @@ public function verify(string $value, string $hash): bool { return $this->hash($value) === $hash; } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return 'sha'; + } } diff --git a/src/Auth/Proofs/Password.php b/src/Auth/Proofs/Password.php index d873d81..4a55a00 100644 --- a/src/Auth/Proofs/Password.php +++ b/src/Auth/Proofs/Password.php @@ -20,7 +20,7 @@ class Password extends Proof public const SCRYPT = 'scrypt'; - public const SCRYPT_MODIFIED = 'scrypt-modified'; + public const SCRYPT_MODIFIED = 'scryptMod'; public const SHA = 'sha'; @@ -60,7 +60,7 @@ public function __construct(array $hashes = []) } $this->hashes = $hashes; - $this->hash = reset($hashes); // Set the first hash as the default one + $this->hash = new Argon2(); // Set the first hash as the default one } /** @@ -171,4 +171,31 @@ public function generate(): string return $password; } + + /** + * Create a hash instance by type + * + * @param string $type One of the supported hash types (ARGON2, BCRYPT, SCRYPT, SCRYPT_MODIFIED, SHA, MD5, PHPASS) + * @param array $options Optional parameters for hash configuration + * @return Hash + * + * @throws \Exception + */ + public static function createHash(string $type, array $options = []): Hash + { + $hash = match ($type) { + self::ARGON2 => new Argon2(), + self::BCRYPT => new Bcrypt(), + self::SCRYPT => new Scrypt(), + self::SCRYPT_MODIFIED => new ScryptModified(), + self::SHA => new Sha(), + self::MD5 => new MD5(), + self::PHPASS => new PHPass(), + default => throw new \Exception("Unsupported hash type: {$type}") + }; + + $hash->setOptions($options); + + return $hash; + } } diff --git a/src/Auth/Store.php b/src/Auth/Store.php index 2ae6766..ccab76d 100644 --- a/src/Auth/Store.php +++ b/src/Auth/Store.php @@ -10,31 +10,59 @@ class Store protected array $data = []; /** - * Get a value from the store + * @var string|null + */ + protected ?string $key = null; + + /** + * Get a property from the store * * @param string $key * @param mixed $default * @return mixed */ - public function get(string $key, mixed $default = null): mixed + public function getProperty(string $key, mixed $default = null): mixed { return $this->data[$key] ?? $default; } /** - * Set a value in the store + * Set a property in the store * * @param string $key * @param mixed $value * @return self */ - public function set(string $key, mixed $value): self + public function setProperty(string $key, mixed $value): self { $this->data[$key] = $value; return $this; } + /** + * Get the store key + * + * @return string|null + */ + public function getKey(): ?string + { + return $this->key; + } + + /** + * Set the store key + * + * @param string|null $key + * @return self + */ + public function setKey(?string $key): self + { + $this->key = $key; + + return $this; + } + /** * Encode store data to base64 string * @@ -66,7 +94,7 @@ public function decode(string $data): self $json = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); if (is_array($json)) { foreach ($json as $key => $value) { - $this->set($key, $value); + $this->setProperty($key, $value); } } } catch (\JsonException $e) { diff --git a/tests/Auth/Algorithms/Argon2Test.php b/tests/Auth/Algorithms/Argon2Test.php index 8690601..b9d46d4 100644 --- a/tests/Auth/Algorithms/Argon2Test.php +++ b/tests/Auth/Algorithms/Argon2Test.php @@ -26,12 +26,6 @@ public function testHash(): void $this->assertFalse($this->argon2->verify('wrongpassword', $hash)); } - public function testMemoryCost(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->argon2->setMemoryCost(1); // Should throw exception for too low memory cost - } - public function testValidMemoryCost(): void { $cost = PASSWORD_ARGON2_DEFAULT_MEMORY_COST + 1024; @@ -43,12 +37,6 @@ public function testValidMemoryCost(): void $this->assertTrue($this->argon2->verify($password, $hash)); } - public function testTimeCost(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->argon2->setTimeCost(0); // Should throw exception for too low time cost - } - public function testValidTimeCost(): void { $cost = PASSWORD_ARGON2_DEFAULT_TIME_COST + 1; @@ -60,12 +48,6 @@ public function testValidTimeCost(): void $this->assertTrue($this->argon2->verify($password, $hash)); } - public function testThreads(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->argon2->setThreads(0); // Should throw exception for too low thread count - } - public function testValidThreads(): void { $threads = PASSWORD_ARGON2_DEFAULT_THREADS + 1; @@ -76,4 +58,9 @@ public function testValidThreads(): void $hash = $this->argon2->hash($password); $this->assertTrue($this->argon2->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('argon2', $this->argon2->getName()); + } } diff --git a/tests/Auth/Algorithms/BcryptTest.php b/tests/Auth/Algorithms/BcryptTest.php index 5168b9c..880ac63 100644 --- a/tests/Auth/Algorithms/BcryptTest.php +++ b/tests/Auth/Algorithms/BcryptTest.php @@ -25,4 +25,9 @@ public function testHash(): void $this->assertTrue($this->bcrypt->verify($password, $hash)); $this->assertFalse($this->bcrypt->verify('wrongpassword', $hash)); } + + public function testGetName(): void + { + $this->assertEquals('bcrypt', $this->bcrypt->getName()); + } } diff --git a/tests/Auth/Algorithms/MD5Test.php b/tests/Auth/Algorithms/MD5Test.php index ccba042..48d0094 100644 --- a/tests/Auth/Algorithms/MD5Test.php +++ b/tests/Auth/Algorithms/MD5Test.php @@ -64,4 +64,9 @@ public function testUnicodeCharacters(): void $this->assertEquals(md5($password), $hash); $this->assertTrue($this->md5->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('md5', $this->md5->getName()); + } } diff --git a/tests/Auth/Algorithms/PHPassTest.php b/tests/Auth/Algorithms/PHPassTest.php index 7b052de..5a83d7b 100644 --- a/tests/Auth/Algorithms/PHPassTest.php +++ b/tests/Auth/Algorithms/PHPassTest.php @@ -82,4 +82,9 @@ public function testLongPassword(): void $hash = $this->phpass->hash($password); $this->assertTrue($this->phpass->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('phpass', $this->phpass->getName()); + } } diff --git a/tests/Auth/Algorithms/PlaintextTest.php b/tests/Auth/Algorithms/PlaintextTest.php index acdcb57..87635ea 100644 --- a/tests/Auth/Algorithms/PlaintextTest.php +++ b/tests/Auth/Algorithms/PlaintextTest.php @@ -52,4 +52,9 @@ public function testEmptyString(): void $this->assertEquals($password, $hash); $this->assertTrue($this->plaintext->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('plaintext', $this->plaintext->getName()); + } } diff --git a/tests/Auth/Algorithms/ScryptModifiedTest.php b/tests/Auth/Algorithms/ScryptModifiedTest.php index 7354a18..fe52133 100644 --- a/tests/Auth/Algorithms/ScryptModifiedTest.php +++ b/tests/Auth/Algorithms/ScryptModifiedTest.php @@ -36,4 +36,9 @@ public function testCustomOptions(): void $this->assertTrue($this->scryptModified->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('scryptMod', $this->scryptModified->getName()); + } } diff --git a/tests/Auth/Algorithms/ScryptTest.php b/tests/Auth/Algorithms/ScryptTest.php index 6b9b606..7bae12f 100644 --- a/tests/Auth/Algorithms/ScryptTest.php +++ b/tests/Auth/Algorithms/ScryptTest.php @@ -38,4 +38,9 @@ public function testCustomOptions(): void $this->assertTrue($this->scrypt->verify($password, $hash)); } + + public function testGetName(): void + { + $this->assertEquals('scrypt', $this->scrypt->getName()); + } } diff --git a/tests/Auth/Algorithms/ShaTest.php b/tests/Auth/Algorithms/ShaTest.php index ebcef15..2955924 100644 --- a/tests/Auth/Algorithms/ShaTest.php +++ b/tests/Auth/Algorithms/ShaTest.php @@ -39,4 +39,9 @@ public function testInvalidVersion(): void $this->expectException(\InvalidArgumentException::class); $this->sha->setVersion('invalid-version'); } + + public function testGetName(): void + { + $this->assertEquals('sha', $this->sha->getName()); + } } diff --git a/tests/Auth/HashTest.php b/tests/Auth/HashTest.php new file mode 100644 index 0000000..84c6dfd --- /dev/null +++ b/tests/Auth/HashTest.php @@ -0,0 +1,82 @@ +hash = new class extends Hash + { + public function hash(string $value): string + { + return 'hashed_'.$value; + } + + public function verify(string $value, string $hash): bool + { + return $hash === 'hashed_'.$value; + } + + public function getName(): string + { + return 'test_hash'; + } + }; + } + + public function testSetAndGetOption(): void + { + $this->hash->setOption('key1', 'value1'); + $this->assertEquals('value1', $this->hash->getOption('key1')); + $this->assertNull($this->hash->getOption('nonexistent')); + $this->assertEquals('default', $this->hash->getOption('nonexistent', 'default')); + } + + public function testSetOptions(): void + { + $options = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => ['nested' => 'value'], + ]; + + $this->hash->setOptions($options); + + // Verify all options were set + $this->assertEquals($options, $this->hash->getOptions()); + + // Verify individual options + foreach ($options as $key => $value) { + $this->assertEquals($value, $this->hash->getOption($key)); + } + } + + public function testGetOptions(): void + { + $options = [ + 'key1' => 'value1', + 'key2' => 'value2', + ]; + + $this->hash->setOptions($options); + $this->assertEquals($options, $this->hash->getOptions()); + } + + public function testMethodChaining(): void + { + $result = $this->hash + ->setOption('key1', 'value1') + ->setOptions(['key2' => 'value2']); + + $this->assertInstanceOf(Hash::class, $result); + $this->assertEquals('value1', $this->hash->getOption('key1')); + $this->assertEquals('value2', $this->hash->getOption('key2')); + } +} diff --git a/tests/Auth/Proofs/PasswordTest.php b/tests/Auth/Proofs/PasswordTest.php index e39c641..41d8d91 100644 --- a/tests/Auth/Proofs/PasswordTest.php +++ b/tests/Auth/Proofs/PasswordTest.php @@ -16,8 +16,6 @@ class PasswordTest extends TestCase { protected Password $password; - protected Password $legacyPassword; - protected Bcrypt $bcrypt; protected function setUp(): void @@ -27,7 +25,6 @@ protected function setUp(): void // Test legacy constructor with explicit hashes $this->bcrypt = new Bcrypt(); - $this->legacyPassword = new Password(['bcrypt' => $this->bcrypt]); } public function testGenerate(): void @@ -122,7 +119,7 @@ public function testRemoveHash(): void { // First try to remove the current hash (should fail) $this->expectException(\Exception::class); - $this->password->removeHash(Password::ARGON2); // Argon2 is the default current hash + $this->password->removeHash('random-hash'); // Argon2 is the default current hash } public function testRemoveNonCurrentHash(): void @@ -166,18 +163,41 @@ public function testAllHashesWork(): void } } - public function testLegacyConstructor(): void + public function testCreateHash(): void { - $proof = $this->password->generate(); - $hash = $this->legacyPassword->hash($proof); - - $this->assertNotEmpty($hash); - $this->assertIsString($hash); - $this->assertStringStartsWith('$2y$', $hash); - $this->assertTrue($this->legacyPassword->verify($proof, $hash)); - - // Verify that only the specified hash is available + // Test default hash creation + $argon2Hash = Password::createHash(Password::ARGON2); + $this->assertInstanceOf(Argon2::class, $argon2Hash); + + $bcryptHash = Password::createHash(Password::BCRYPT); + $this->assertInstanceOf(Bcrypt::class, $bcryptHash); + + // Test hash creation with options + $customBcrypt = Password::createHash(Password::BCRYPT, [ + 'cost' => 8, + ]); + $this->assertInstanceOf(Bcrypt::class, $customBcrypt); + + $customScrypt = Password::createHash(Password::SCRYPT, [ + 'cpu_cost' => 8192, + 'memory_cost' => 4, + 'parallel_cost' => 1, + 'key_length' => 32, + ]); + $this->assertInstanceOf(Scrypt::class, $customScrypt); + + // Test invalid hash type $this->expectException(\Exception::class); - $this->legacyPassword->getHashByName(Password::ARGON2); + $this->expectExceptionMessage('Unsupported hash type: invalid-hash'); + Password::createHash('invalid-hash'); + } + + public function testCreateHashWithInvalidOptions(): void + { + // Test that invalid options are ignored + $hash = Password::createHash(Password::BCRYPT, [ + 'invalid_option' => 'value', + ]); + $this->assertInstanceOf(Bcrypt::class, $hash); } } diff --git a/tests/StoreTest.php b/tests/StoreTest.php index ade98f9..9b1c1b4 100644 --- a/tests/StoreTest.php +++ b/tests/StoreTest.php @@ -7,28 +7,49 @@ class StoreTest extends TestCase { - public function testGetAndSet(): void + public function testGetAndSetProperty(): void { $store = new Store(); // Test setting and getting a string - $store->set('name', 'John Doe'); - $this->assertEquals('John Doe', $store->get('name')); + $store->setProperty('name', 'John Doe'); + $this->assertEquals('John Doe', $store->getProperty('name')); // Test setting and getting different types - $store->set('age', 30) - ->set('active', true) - ->set('scores', [95, 87, 92]) - ->set('details', ['city' => 'New York', 'country' => 'USA']); + $store->setProperty('age', 30) + ->setProperty('active', true) + ->setProperty('scores', [95, 87, 92]) + ->setProperty('details', ['city' => 'New York', 'country' => 'USA']); - $this->assertEquals(30, $store->get('age')); - $this->assertTrue($store->get('active')); - $this->assertEquals([95, 87, 92], $store->get('scores')); - $this->assertEquals(['city' => 'New York', 'country' => 'USA'], $store->get('details')); + $this->assertEquals(30, $store->getProperty('age')); + $this->assertTrue($store->getProperty('active')); + $this->assertEquals([95, 87, 92], $store->getProperty('scores')); + $this->assertEquals(['city' => 'New York', 'country' => 'USA'], $store->getProperty('details')); // Test default value for non-existent key - $this->assertNull($store->get('nonexistent')); - $this->assertEquals('default', $store->get('nonexistent', 'default')); + $this->assertNull($store->getProperty('nonexistent')); + $this->assertEquals('default', $store->getProperty('nonexistent', 'default')); + } + + public function testGetAndSetKey(): void + { + $store = new Store(); + + // Test initial key is null + $this->assertNull($store->getKey()); + + // Test setting and getting a key + $store->setKey('test-key'); + $this->assertEquals('test-key', $store->getKey()); + + // Test setting key to null + $store->setKey(null); + $this->assertNull($store->getKey()); + + // Test method chaining + $store->setKey('new-key')->setProperty('test', 'value'); + $this->assertEquals('new-key', $store->getKey()); + $this->assertEquals('value', $store->getProperty('test')); } public function testEncodeAndDecode(): void @@ -42,10 +63,11 @@ public function testEncodeAndDecode(): void 'details' => ['city' => 'New York', 'country' => 'USA'], ]; - // Set multiple values + // Set multiple values and key foreach ($data as $key => $value) { - $store->set($key, $value); + $store->setProperty($key, $value); } + $store->setKey('test-key'); // Encode the store $encoded = $store->encode(); @@ -61,7 +83,7 @@ public function testEncodeAndDecode(): void // Verify all data was preserved foreach ($data as $key => $value) { - $this->assertEquals($value, $newStore->get($key)); + $this->assertEquals($value, $store->getProperty($key)); } } @@ -71,23 +93,23 @@ public function testDecodeInvalidData(): void // Test decoding invalid base64 $store->decode('invalid-base64'); - $this->assertNull($store->get('any')); + $this->assertNull($store->getProperty('any')); // Test decoding valid base64 but invalid JSON $store->decode(base64_encode('invalid-json')); - $this->assertNull($store->get('any')); + $this->assertNull($store->getProperty('any')); // Test decoding valid base64 and JSON, but not an array $json = json_encode('string', JSON_THROW_ON_ERROR); $store->decode(base64_encode($json)); - $this->assertNull($store->get('any')); + $this->assertNull($store->getProperty('any')); } public function testEncodeWithInvalidData(): void { $store = new Store(); // Create an invalid UTF-8 string that will cause json_encode to fail - $store->set('invalid', "\xFF"); + $store->setProperty('invalid', "\xFF"); $this->expectException(\JsonException::class); $store->encode();