Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
CaptchaUtils
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
4 / 4
21
100.00% covered (success)
100.00%
1 / 1
 generateOlzCaptchaConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getRandomString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateToken
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
17
 getAppEnv
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Olz\Captcha\Utils;
4
5use Olz\Utils\WithUtilsTrait;
6
7/**
8 * @phpstan-type OlzCaptchaConfig array{
9 *   rand: string,
10 *   date: string,
11 *   mac: string,
12 * }
13 */
14class CaptchaUtils {
15    use WithUtilsTrait;
16
17    public const WID = 350;
18    public const HEI = 200;
19    public const INP_WID = 250;
20    public const INP_HEI = 24;
21
22    /** @return OlzCaptchaConfig */
23    public function generateOlzCaptchaConfig(int $length): array {
24        // TODO: Derive from app secret?
25        $key = $this->envUtils()->getEmailReactionKey();
26        $rand = $this->getRandomString($length);
27        $date = $this->dateUtils()->getIsoNow();
28        $mac = $this->generalUtils()->hash($key, "{$date}{$rand}");
29        return [
30            'rand' => $rand,
31            'date' => $date,
32            'mac' => $mac,
33        ];
34    }
35
36    public function getRandomString(int $length): string {
37        $app_env = $this->getAppEnv();
38        if ($app_env === 'dev') {
39            return base64_encode(str_repeat('a', $length));
40        }
41        return base64_encode(openssl_random_pseudo_bytes($length));
42    }
43
44    public function validateToken(?string $token): bool {
45        if (!$token) {
46            $this->log()->error("No captcha token provided.");
47            return false;
48        }
49        $app_env = $this->getAppEnv();
50        if ($app_env === 'dev' && $token === 'dev') {
51            $this->log()->notice("Accept '{$token}' captcha, because env is '{$app_env}'");
52            return true;
53        }
54
55        $key = $this->envUtils()->getEmailReactionKey();
56        $now = $this->dateUtils()->getIsoNow();
57
58        $content = json_decode(base64_decode($token), true);
59        $log = $content['log'] ?? [];
60        $config = $content['config'] ?? [];
61        $date = $config['date'] ?? '';
62        $rand = $config['rand'] ?? '';
63        $mac = $config['mac'] ?? '';
64        $expected_mac = $this->generalUtils()->hash($key, "{$date}{$rand}");
65        if ($mac !== $expected_mac) {
66            $this->log()->info("Captcha denied: Invalid MAC of '{$date}{$rand}': {$mac}", []);
67            return false;
68        }
69        $validity_interval = \DateInterval::createFromDateString("+60 seconds");
70        $end_of_validity = (new \DateTime($date))->add($validity_interval)->format('Y-m-d H:i:s');
71        if ($end_of_validity < $now) {
72            $this->log()->info("Captcha denied: End of validity was at {$end_of_validity}", []);
73            return false;
74        }
75
76        $bytes = base64_decode($rand) ?: '';
77        $len = strlen($bytes);
78        if ($len !== 3) {
79            $this->log()->warning("Captcha denied: Rand length must be 3, was {$len}", []);
80            return false;
81        }
82        $x_start = ord($bytes[0]) / 255.0 * (self::WID - self::INP_WID - 25) + 10;
83        $y_bar = ord($bytes[1]) / 255.0 * (self::HEI - self::INP_HEI) + 12;
84        $target = round(2 + ord($bytes[2]) / 20.0);
85        $x_end_min = $x_start + ($target - 0.55) / 15 * (self::INP_WID - 20);
86        $x_end_max = $x_start + ($target + 0.55) / 15 * (self::INP_WID - 20);
87        $context = [
88            'log' => $log,
89            'config' => $config,
90            'x_start' => $x_start,
91            'y_bar' => $y_bar,
92            'target' => $target,
93            'x_end_min' => $x_end_min,
94            'x_end_max' => $x_end_max,
95        ];
96        $constraints = [false, false];
97        for ($i = 0; $i < count($log); $i++) {
98            $entry = $log[$i];
99            $res = preg_match('/^(D|M|U)(\-?[0-9]+)\,(\-?[0-9]+)$/', $entry, $matches);
100            if (!$res) {
101                $this->log()->info("Captcha denied: Log[{$i}] = '{$entry}' does not match pattern", $context);
102                return false;
103            }
104            $event = $matches[1];
105            $x = intval($matches[2]);
106            $y = intval($matches[3]);
107            if ($event === 'D' && pow($x - $x_start, 2) + pow($y - $y_bar, 2) <= pow(12, 2)) {
108                $constraints[0] = true;
109            }
110            if ($event === 'U' && $x >= $x_end_min && $x <= $x_end_max) {
111                $constraints[1] = true;
112            }
113        }
114        for ($i = 0; $i < count($constraints); $i++) {
115            if (!$constraints[$i]) {
116                $this->log()->info("Captcha denied: Constraint[{$i}] failed", $context);
117                return false;
118            }
119        }
120        $this->log()->info('Captcha succeeded', $context);
121        return true;
122    }
123
124    protected function getAppEnv(): string {
125        return $this->envUtils()->getAppEnv();
126    }
127}