diff --git a/.docs/README.md b/.docs/README.md index 9fb1440..a4babf6 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -8,6 +8,8 @@ - [StandaloneFormFactoryExtension](#standalone-form-factory) (Nette\Forms\Form) - Controls - [Date/time inputs](#date-time-inputs) (DateTimeInput, DateInput, TimeInput) +- Captcha + - [Wordcha](#wordcha) (Question-based captcha) ## Setup @@ -346,3 +348,72 @@ $control->setValueAs(DateTime::class); // value in input timezone as \DateTime $control->setValueInTzAs(DateTime::class); // value in server default timezone as \DateTime $control->setValueInTzAs(DateTime::class, new DateTimeZone('Americe/New_York')); // value in given timezone as \DateTime ``` + +## Captcha + +### Wordcha + +Question-based captcha for Nette Framework / Forms. + +#### Wordcha Setup + +Register extension + +```yaml +extensions: + wordcha: Contributte\Forms\Captcha\Wordcha\DI\WordchaExtension +``` + +#### Wordcha Configuration + +At the beginning you should pick the right datasource. + +##### Numeric datasource + +```yaml +wordcha: + datasource: numeric +``` + +##### Question datasource + +```yaml +wordcha: + datasource: questions + questions: + "Question a?": "a" + "Question b?": "b" +``` + +#### Wordcha Form Usage + +```php +use Nette\Application\UI\Form; + +protected function createComponentForm() +{ + $form = new Form(); + + $form->addWordcha('wordcha') + ->getQuestion() + ->setRequired('Please answer antispam question'); + + $form->addSubmit('send'); + + $form->onValidate[] = function (Form $form) { + if ($form['wordcha']->verify() !== TRUE) { + $form->addError('Are you robot?'); + } + }; + + $form->onSuccess[] = function (Form $form) { + dump($form['wordcha']); + }; + + return $form; +} +``` + +#### Wordcha Example + +![captcha](wordcha.png) diff --git a/.docs/wordcha.png b/.docs/wordcha.png new file mode 100644 index 0000000..2312908 Binary files /dev/null and b/.docs/wordcha.png differ diff --git a/src/Captcha/Wordcha/DI/FormBinder.php b/src/Captcha/Wordcha/DI/FormBinder.php new file mode 100644 index 0000000..15c0ed3 --- /dev/null +++ b/src/Captcha/Wordcha/DI/FormBinder.php @@ -0,0 +1,20 @@ + $container[$name] = new WordchaContainer($factory) // @phpcs:ignore + ); + } + +} diff --git a/src/Captcha/Wordcha/DI/WordchaExtension.php b/src/Captcha/Wordcha/DI/WordchaExtension.php new file mode 100644 index 0000000..b9c8741 --- /dev/null +++ b/src/Captcha/Wordcha/DI/WordchaExtension.php @@ -0,0 +1,94 @@ +debugMode = $debugMode; + } + + public function getConfigSchema(): Schema + { + return Expect::structure([ + 'auto' => Expect::bool()->default(true), + 'datasource' => Expect::anyOf(...self::DATASOURCES)->default(self::DATASOURCE_NUMERIC), + 'questions' => Expect::listOf('string'), + ]); + } + + /** + * Register services + * + * @throws AssertionException + */ + public function loadConfiguration(): void + { + $builder = $this->getContainerBuilder(); + + // Add datasource + $dataSource = $builder->addDefinition($this->prefix('dataSource')) + ->setType(DataSource::class); + + if ($this->config->datasource === self::DATASOURCE_NUMERIC) { + $dataSource->setFactory(NumericDataSource::class); + } elseif ($this->config->datasource === self::DATASOURCE_QUESTIONS) { + $dataSource->setFactory(QuestionDataSource::class, [$this->config->questions]); + } + + // Add factory + $factory = $builder->addDefinition($this->prefix('factory')) + ->setType(Factory::class); + if ($this->debugMode) { + $factory->setFactory(WordchaFactory::class, [$dataSource]); + } else { + $uniqueKey = sha1(random_bytes(10) . microtime(true)); + $factory->setFactory(WordchaUniqueFactory::class, [$dataSource, $uniqueKey]); + } + } + + public function afterCompile(ClassType $class): void + { + if ($this->config->auto === true) { + + $method = $class->getMethod('initialize'); + $method->addBody( + '?::bind($this->getService(?));', + [ + new Literal(FormBinder::class), + $this->prefix('factory'), + ] + ); + } + } + +} diff --git a/src/Captcha/Wordcha/DataSource/DataSource.php b/src/Captcha/Wordcha/DataSource/DataSource.php new file mode 100644 index 0000000..d5ffada --- /dev/null +++ b/src/Captcha/Wordcha/DataSource/DataSource.php @@ -0,0 +1,10 @@ + $max) { + throw new LogicalException(sprintf('Min (%d) must be less than or equal to max (%d)', $min, $max)); + } + + $this->min = $min; + $this->max = $max; + } + + public function get(): Pair + { + $numberA = $this->generateNumber(); + $numberB = $this->generateNumber(); + + $question = sprintf('%s + %s', $numberA, $numberB); + $answer = $numberA + $numberB; + + return new Pair($question, (string) $answer); + } + + private function generateNumber(): int + { + return random_int($this->min, $this->max); + } + +} diff --git a/src/Captcha/Wordcha/DataSource/Pair.php b/src/Captcha/Wordcha/DataSource/Pair.php new file mode 100644 index 0000000..52991be --- /dev/null +++ b/src/Captcha/Wordcha/DataSource/Pair.php @@ -0,0 +1,28 @@ +question = $question; + $this->answer = $answer; + } + + public function getQuestion(): string + { + return $this->question; + } + + public function getAnswer(): string + { + return $this->answer; + } + +} diff --git a/src/Captcha/Wordcha/DataSource/QuestionDataSource.php b/src/Captcha/Wordcha/DataSource/QuestionDataSource.php new file mode 100644 index 0000000..96aa043 --- /dev/null +++ b/src/Captcha/Wordcha/DataSource/QuestionDataSource.php @@ -0,0 +1,37 @@ + Pairs of question:answer */ + private array $questions; + + /** + * @param array $questions + */ + public function __construct(array $questions) + { + $this->questions = $questions; + } + + /** + * @throws Exception + */ + public function get(): Pair + { + if ($this->questions === []) { + throw new LogicalException('Questions are empty'); + } + + $question = array_rand($this->questions); + $answer = $this->questions[$question]; + + return new Pair($question, $answer); + } + +} diff --git a/src/Captcha/Wordcha/Exception/LogicalException.php b/src/Captcha/Wordcha/Exception/LogicalException.php new file mode 100644 index 0000000..8036324 --- /dev/null +++ b/src/Captcha/Wordcha/Exception/LogicalException.php @@ -0,0 +1,8 @@ +validator = $factory->createValidator(); + $this->generator = $factory->createGenerator(); + + $security = $this->generator->generate(); + + $textInput = new TextInput($security->getQuestion()); + $textInput->setRequired(true); + + $hiddenField = new HiddenField($security->getHash()); + + $this['question'] = $textInput; + $this['hash'] = $hiddenField; + } + + public function getQuestion(): TextInput + { + $control = $this->getComponent('question'); + assert($control instanceof TextInput); + + return $control; + } + + public function getHash(): HiddenField + { + $control = $this->getComponent('hash'); + assert($control instanceof HiddenField); + + return $control; + } + + public function verify(): bool + { + /** @var Form $form */ + $form = $this->getForm(); + + /** @var string $hash */ + $hash = $form->getHttpData(Form::DataLine, $this->getHash()->getHtmlName()); + + /** @var string $answer */ + $answer = $form->getHttpData(Form::DataLine, $this->getQuestion()->getHtmlName()); + + $answer = Strings::lower($answer); + + return $this->validator->validate($answer, $hash); + } + + public function getValidator(): Validator + { + return $this->validator; + } + + public function getGenerator(): Generator + { + return $this->generator; + } + +} diff --git a/src/Captcha/Wordcha/Generator/Generator.php b/src/Captcha/Wordcha/Generator/Generator.php new file mode 100644 index 0000000..bfd7ca0 --- /dev/null +++ b/src/Captcha/Wordcha/Generator/Generator.php @@ -0,0 +1,12 @@ +question = $question; + $this->hash = $hash; + } + + public function getQuestion(): string + { + return $this->question; + } + + public function getHash(): string + { + return $this->hash; + } + +} diff --git a/src/Captcha/Wordcha/Generator/WordchaGenerator.php b/src/Captcha/Wordcha/Generator/WordchaGenerator.php new file mode 100644 index 0000000..50050c3 --- /dev/null +++ b/src/Captcha/Wordcha/Generator/WordchaGenerator.php @@ -0,0 +1,42 @@ +dataSource = $dataSource; + } + + public function setUniqueKey(string $uniqueKey): void + { + $this->uniqueKey = $uniqueKey; + } + + public function generate(): Security + { + $pair = $this->dataSource->get(); + $hash = $this->hash($pair->getAnswer()); + $question = $pair->getQuestion(); + + return new Security($question, $hash); + } + + public function hash(string $answer): string + { + if ($this->uniqueKey !== null) { + $answer .= $this->uniqueKey; + } + + return sha1(strtolower($answer)); + } + +} diff --git a/src/Captcha/Wordcha/Validator/Validator.php b/src/Captcha/Wordcha/Validator/Validator.php new file mode 100644 index 0000000..bd248e0 --- /dev/null +++ b/src/Captcha/Wordcha/Validator/Validator.php @@ -0,0 +1,10 @@ +generator = $generator; + } + + public function validate(string $answer, string $hash): bool + { + $answerHash = $this->generator->hash($answer); + + return $hash === $answerHash; + } + +} diff --git a/src/Captcha/Wordcha/WordchaFactory.php b/src/Captcha/Wordcha/WordchaFactory.php new file mode 100644 index 0000000..17bf0d1 --- /dev/null +++ b/src/Captcha/Wordcha/WordchaFactory.php @@ -0,0 +1,37 @@ +dataSource = $dataSource; + } + + /** + * @return WordchaValidator + */ + public function createValidator(): Validator + { + return new WordchaValidator($this->createGenerator()); + } + + /** + * @return WordchaGenerator + */ + public function createGenerator(): Generator + { + return new WordchaGenerator($this->dataSource); + } + +} diff --git a/src/Captcha/Wordcha/WordchaUniqueFactory.php b/src/Captcha/Wordcha/WordchaUniqueFactory.php new file mode 100644 index 0000000..457d6c7 --- /dev/null +++ b/src/Captcha/Wordcha/WordchaUniqueFactory.php @@ -0,0 +1,32 @@ +uniqueKey = $uniqueKey; + } + + /** + * @return WordchaGenerator + */ + public function createGenerator(): Generator + { + $generator = parent::createGenerator(); + $generator->setUniqueKey($this->uniqueKey); + + return $generator; + } + +} diff --git a/tests/Cases/Wordcha/DI/FormBinder.phpt b/tests/Cases/Wordcha/DI/FormBinder.phpt new file mode 100644 index 0000000..ede26fc --- /dev/null +++ b/tests/Cases/Wordcha/DI/FormBinder.phpt @@ -0,0 +1,46 @@ +shouldReceive('generate') + ->andReturn(new Security('..', $hash)) + ->getMock() + ->shouldReceive('hash') + ->andReturn($hash) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createGenerator') + ->andReturn($generator) + ->getMock(); + + FormBinder::bind($factory); + + $form = new Form(); + $captcha = $form->addWordcha(); + + Assert::type(WordchaContainer::class, $captcha); + Assert::type(TextInput::class, $captcha['question']); + Assert::type(HiddenField::class, $captcha['hash']); + + Assert::equal($hash, $captcha['hash']->getValue()); +}); diff --git a/tests/Cases/Wordcha/DataSource/NumericDataSource.phpt b/tests/Cases/Wordcha/DataSource/NumericDataSource.phpt new file mode 100644 index 0000000..180dac3 --- /dev/null +++ b/tests/Cases/Wordcha/DataSource/NumericDataSource.phpt @@ -0,0 +1,65 @@ +get(); + + Assert::type('string', $pair->getQuestion()); + Assert::type('string', $pair->getAnswer()); + Assert::match('~^\d+ \+ \d+$~', $pair->getQuestion()); + + // Answer should be between 0 and 20 (0+0 to 10+10) + $answer = (int) $pair->getAnswer(); + Assert::true($answer >= 0 && $answer <= 20); +}); + +// Test custom min/max values +Toolkit::test(function (): void { + $dataSource = new NumericDataSource(5, 15); + $pair = $dataSource->get(); + + Assert::type('string', $pair->getQuestion()); + Assert::type('string', $pair->getAnswer()); + Assert::match('~^\d+ \+ \d+$~', $pair->getQuestion()); + + // Answer should be between 10 and 30 (5+5 to 15+15) + $answer = (int) $pair->getAnswer(); + Assert::true($answer >= 10 && $answer <= 30); +}); + +// Test min equals max +Toolkit::test(function (): void { + $dataSource = new NumericDataSource(5, 5); + $pair = $dataSource->get(); + + Assert::equal('5 + 5', $pair->getQuestion()); + Assert::equal('10', $pair->getAnswer()); +}); + +// Test min greater than max throws exception +Toolkit::test(function (): void { + Assert::exception(function (): void { + new NumericDataSource(10, 5); + }, LogicalException::class, 'Min (10) must be less than or equal to max (5)'); +}); + +// Test negative numbers +Toolkit::test(function (): void { + $dataSource = new NumericDataSource(-5, 5); + $pair = $dataSource->get(); + + Assert::type('string', $pair->getQuestion()); + Assert::type('string', $pair->getAnswer()); + + // Answer should be between -10 and 10 (-5+-5 to 5+5) + $answer = (int) $pair->getAnswer(); + Assert::true($answer >= -10 && $answer <= 10); +}); diff --git a/tests/Cases/Wordcha/Forms/WordchaContainer.phpt b/tests/Cases/Wordcha/Forms/WordchaContainer.phpt new file mode 100644 index 0000000..82686a3 --- /dev/null +++ b/tests/Cases/Wordcha/Forms/WordchaContainer.phpt @@ -0,0 +1,96 @@ +shouldReceive('generate') + ->andReturn(new Security('..', $hash)) + ->getMock() + ->shouldReceive('hash') + ->andReturn($hash) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createGenerator') + ->andReturn($generator) + ->getMock(); + + $captcha = new WordchaContainer($factory); + Assert::type(WordchaContainer::class, $captcha); + Assert::type(TextInput::class, $captcha['question']); + Assert::type(HiddenField::class, $captcha['hash']); + + Assert::equal($hash, $captcha['hash']->getValue()); +}); + +Toolkit::test(function (): void { + $hash = '12345'; + $validator = Mockery::mock(Validator::class) + ->shouldReceive('validate') + ->andReturn(true) + ->getMock(); + + $generator = Mockery::mock(Generator::class) + ->shouldReceive('generate') + ->andReturn(new Security('..', $hash)) + ->getMock() + ->shouldReceive('hash') + ->andReturn($hash) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createGenerator') + ->andReturn($generator) + ->getMock(); + + $captcha = new WordchaContainer($factory); + $validator = $captcha->getValidator(); + + Assert::true($validator->validate('foo', 'bar')); +}); + +Toolkit::test(function (): void { + $hash = '12345'; + + $validator = Mockery::mock(Validator::class) + ->shouldReceive('validate') + ->andReturn(false) + ->getMock(); + + $generator = Mockery::mock(Generator::class) + ->shouldReceive('generate') + ->andReturn(new Security('..', $hash)) + ->getMock() + ->shouldReceive('hash') + ->andReturn($hash) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createGenerator') + ->andReturn($generator) + ->getMock(); + + $captcha = new WordchaContainer($factory); + $validator = $captcha->getValidator(); + + Assert::false($validator->validate('foo', 'bar')); +});