diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef80e7..811cbc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip - Leetspeak character converter. - Word converter creating all combinations of upper case and lower case letters in words. - Possible to use word converters in dictionaries. Useful for converting leetspeak to normal characters before checking the word list. +- Possible to use word converters in guessable data rules. Useful for converting leetspeak to normal characters before comparing to the guessable data. ### Changed - Case converters return a traversable with strings instead of a single string. diff --git a/src/Rule/GuessableData.php b/src/Rule/GuessableData.php index 93ed25b..2626d00 100644 --- a/src/Rule/GuessableData.php +++ b/src/Rule/GuessableData.php @@ -7,8 +7,10 @@ use DateTimeInterface; use InvalidArgumentException; use RuntimeException; +use Traversable; use Stadly\PasswordPolice\Password; use Stadly\PasswordPolice\Policy; +use Stadly\PasswordPolice\WordConverter\WordConverterInterface; use Stadly\PasswordPolice\WordList\WordListInterface; final class GuessableData implements RuleInterface @@ -54,6 +56,19 @@ final class GuessableData implements RuleInterface ', ', ]; + /** + * @var WordConverterInterface[] Word converters. + */ + private $wordConverters; + + /** + * @param WordConverterInterface... $wordConverters Word converters. + */ + public function __construct(WordConverterInterface... $wordConverters) + { + $this->wordConverters = $wordConverters; + } + /** * Check whether a password is in compliance with the rule. * @@ -89,15 +104,49 @@ public function enforce($password): void private function getGuessableData($password) { if ($password instanceof Password) { - foreach ($password->getGuessableData() as $data) { - if ($this->contains((string)$password, $data)) { - return $data; + foreach ($this->getWordsToCheck((string)$password) as $word) { + foreach ($password->getGuessableData() as $data) { + if ($this->contains($word, $data)) { + return $data; + } } } } return null; } + /** + * @param string $word Word to check. + * @return Traversable Variants of the word to check. + */ + private function getWordsToCheck(string $word): Traversable + { + $checked = []; + foreach ($this->getConvertedWords($word) as $wordToCheck) { + if (isset($checked[$wordToCheck])) { + continue; + } + + $checked[$wordToCheck] = true; + yield $wordToCheck; + } + } + + /** + * @param string $word Word to convert. + * @return Traversable Converted words. May contain duplicates. + */ + private function getConvertedWords(string $word): Traversable + { + yield $word; + + foreach ($this->wordConverters as $wordConverter) { + foreach ($wordConverter->convert($word) as $converted) { + yield $converted; + } + } + } + /** * @param string $password Password to check. * @param string|DateTimeInterface $data Data to check. diff --git a/tests/Rule/GuessableDataTest.php b/tests/Rule/GuessableDataTest.php index 8f01c77..738a974 100644 --- a/tests/Rule/GuessableDataTest.php +++ b/tests/Rule/GuessableDataTest.php @@ -7,6 +7,7 @@ use DateTime; use PHPUnit\Framework\TestCase; use Stadly\PasswordPolice\Password; +use Stadly\PasswordPolice\WordConverter\WordConverterInterface; /** * @coversDefaultClass \Stadly\PasswordPolice\Rule\GuessableData @@ -15,6 +16,18 @@ */ final class GuessableDataTest extends TestCase { + /** + * @covers ::__construct + */ + public function testCanConstructRule(): void + { + $rule = new GuessableData(); + + // Force generation of code coverage + $ruleConstruct = new GuessableData(); + self::assertEquals($rule, $ruleConstruct); + } + /** * @covers ::test */ @@ -69,6 +82,94 @@ public function testPasswordCanNotContainDate(): void self::assertTrue($rule->test($password)); } + /** + * @covers ::test + */ + public function testStringIsRecognizedAfterSingleWordConverter(): void + { + $wordConverter = $this->createMock(WordConverterInterface::class); + $wordConverter->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['4', '€'], ['a', 'e'], $word); + } + ); + + $rule = new GuessableData($wordConverter); + + self::assertFalse($rule->test(new Password('pine4ppl€jack', ['apple']))); + self::assertTrue($rule->test(new Password('pine4pp1€jack', ['apple']))); + } + + /** + * @covers ::test + */ + public function testDateIsRecognizedAfterSingleWordConverter(): void + { + $wordConverter = $this->createMock(WordConverterInterface::class); + $wordConverter->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['I', 'B'], ['1', '8'], $word); + } + ); + + $rule = new GuessableData($wordConverter); + + self::assertFalse($rule->test(new Password('foo2B/II/1Bbar', [new DateTime('2018-11-28')]))); + self::assertTrue($rule->test(new Password('fooZB/I!/1Bbar', [new DateTime('2018-11-28')]))); + } + + /** + * @covers ::test + */ + public function testStringIsRecognizedAfterMultipleWordConverters(): void + { + $wordConverter1 = $this->createMock(WordConverterInterface::class); + $wordConverter1->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['4'], ['a'], $word); + } + ); + + $wordConverter2 = $this->createMock(WordConverterInterface::class); + $wordConverter2->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['€'], ['e'], $word); + } + ); + + $rule = new GuessableData($wordConverter1, $wordConverter2); + + self::assertTrue($rule->test(new Password('pine4ppl€jack', ['apple']))); + self::assertFalse($rule->test(new Password('pineappl€jack', ['apple']))); + self::assertFalse($rule->test(new Password('pine4pplejack', ['apple']))); + } + + /** + * @covers ::test + */ + public function testDateIsRecognizedAfterMultipleWordConverters(): void + { + $wordConverter1 = $this->createMock(WordConverterInterface::class); + $wordConverter1->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['I'], ['1'], $word); + } + ); + + $wordConverter2 = $this->createMock(WordConverterInterface::class); + $wordConverter2->method('convert')->willReturnCallback( + function ($word) { + yield str_replace(['B'], ['8'], $word); + } + ); + + $rule = new GuessableData($wordConverter1, $wordConverter2); + + self::assertTrue($rule->test(new Password('foo2B/I!/1Bbar', [new DateTime('2018-11-28')]))); + self::assertFalse($rule->test(new Password('foo28/II/18bar', [new DateTime('2018-11-28')]))); + self::assertFalse($rule->test(new Password('foo2B/11/1Bbar', [new DateTime('2018-11-28')]))); + } + /** * @covers ::enforce */