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