Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.00% covered (warning)
87.00%
87 / 100
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CaptchaService
87.00% covered (warning)
87.00%
87 / 100
50.00% covered (danger)
50.00%
3 / 6
21.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 ensureValid
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 getCaptchaDetails
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateToken
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createChallenge
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
5.12
 verifySolution
84.91% covered (warning)
84.91%
45 / 53
0.00% covered (danger)
0.00%
0 / 1
11.42
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Captcha;
6
7use BO\Zmscitizenapi\Utils\ClientIpHelper;
8use BO\Zmscitizenapi\Models\CaptchaInterface;
9use BO\Zmsentities\Schema\Entity;
10use Firebase\JWT\JWT;
11use GuzzleHttp\Client;
12use GuzzleHttp\Exception\RequestException;
13
14class CaptchaService extends Entity implements CaptchaInterface
15{
16    public static $schema = "citizenapi/captcha/altchaCaptcha.json";
17    public string $service;
18    private string $siteKey;
19    private string $siteSecret;
20    private string $tokenSecret;
21    private int $tokenExpirationSeconds;
22    public string $challengeUrl;
23    public string $verifyUrl;
24    protected Client $httpClient;
25    public function __construct()
26    {
27        $this->service = 'CaptchaService';
28        $this->siteKey = \App::$ALTCHA_CAPTCHA_SITE_KEY;
29        $this->siteSecret = \App::$ALTCHA_CAPTCHA_SITE_SECRET;
30        $this->tokenSecret = \App::$CAPTCHA_TOKEN_SECRET;
31        $this->tokenExpirationSeconds = \App::$CAPTCHA_TOKEN_TTL;
32        $this->challengeUrl = \App::$ALTCHA_CAPTCHA_ENDPOINT_CHALLENGE;
33        $this->verifyUrl = \App::$ALTCHA_CAPTCHA_ENDPOINT_VERIFY;
34        $this->httpClient = new Client(['verify' => false]);
35        $this->ensureValid();
36    }
37
38    private function ensureValid()
39    {
40        if (!$this->testValid()) {
41            throw new \InvalidArgumentException("The provided data is invalid according to the schema.");
42        }
43    }
44
45    #[\Override]
46    public function getCaptchaDetails(): array
47    {
48        return [
49            'siteKey' => $this->siteKey,
50            'captchaChallenge' => $this->challengeUrl,
51            'captchaVerify' => $this->verifyUrl,
52            'captchaEnabled' => \App::$CAPTCHA_ENABLED
53        ];
54    }
55
56    public function generateToken(): string
57    {
58        $payload = [
59            'ip' => ClientIpHelper::getClientIp(),
60            'iat' => time(),
61            'exp' => time() + $this->tokenExpirationSeconds,
62        ];
63
64        return JWT::encode($payload, $this->tokenSecret, 'HS256');
65    }
66
67    #[\Override]
68    public function createChallenge(): array
69    {
70        try {
71            $response = $this->httpClient->post($this->challengeUrl, [
72                'json' => [
73                    'siteKey' => $this->siteKey,
74                    'siteSecret' => $this->siteSecret,
75                    'clientAddress' => ClientIpHelper::getClientIp(),
76                ]
77            ]);
78
79            $responseData = json_decode((string) $response->getBody(), true);
80
81            if (json_last_error() !== JSON_ERROR_NONE) {
82                throw new \Exception('Error decoding the JSON response');
83            }
84
85            $challenge = $responseData['challenge'] ?? null;
86
87            if ($challenge === null) {
88                throw new \Exception('Missing challenge data');
89            }
90
91            return $challenge;
92        } catch (RequestException $e) {
93            return [
94                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
95                'data' => null,
96            ];
97        } catch (\Throwable $e) {
98            return [
99                'meta' => ['success' => false, 'error' => $e->getMessage()],
100                'data' => null,
101            ];
102        }
103    }
104
105    #[\Override]
106    public function verifySolution(?string $payload): array
107    {
108        if (!$payload) {
109            return [
110                'meta' => ['success' => false, 'error' => 'No payload provided'],
111                'data' => null,
112            ];
113        }
114
115        $decodedJson = base64_decode(strtr($payload, '-_', '+/'));
116        if (!$decodedJson) {
117            return [
118                'meta' => ['success' => false, 'error' => 'Payload could not be decoded'],
119                'data' => null,
120            ];
121        }
122
123        $decodedPayload = json_decode($decodedJson, true);
124        if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPayload)) {
125            return [
126                'meta' => ['success' => false, 'error' => 'Invalid JSON in payload'],
127                'data' => null,
128            ];
129        }
130
131        try {
132            $response = $this->httpClient->post($this->verifyUrl, [
133                'json' => [
134                    'siteKey' => $this->siteKey,
135                    'siteSecret' => $this->siteSecret,
136                    'clientAddress' => ClientIpHelper::getClientIp(),
137                    'payload' => $decodedPayload,
138                ]
139            ]);
140
141            $responseData = json_decode((string) $response->getBody(), true);
142
143            if (json_last_error() !== JSON_ERROR_NONE || !is_array($responseData)) {
144                throw new \Exception('Response from Captcha service is not valid JSON');
145            }
146
147            if (!array_key_exists('valid', $responseData)) {
148                return [
149                    'meta' => ['success' => false, 'error' => 'Response does not contain a "valid" field'],
150                    'data' => $responseData,
151                ];
152            }
153
154            if ($responseData['valid'] !== true) {
155                return [
156                    'meta' => ['success' => false, 'error' => 'Captcha verification failed'],
157                    'data' => $responseData,
158                ];
159            }
160
161            return [
162                'meta' => ['success' => true],
163                'data' => $responseData,
164                'token' => $this->generateToken(),
165            ];
166        } catch (RequestException $e) {
167            return [
168                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
169                'data' => null,
170            ];
171        } catch (\Throwable $e) {
172            return [
173                'meta' => ['success' => false, 'error' => $e->getMessage()],
174                'data' => null,
175            ];
176        }
177    }
178}