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    /**
39     * @return void
40     */
41    private function ensureValid()
42    {
43        if (!$this->testValid()) {
44            throw new \InvalidArgumentException("The provided data is invalid according to the schema.");
45        }
46    }
47
48    #[\Override]
49    public function getCaptchaDetails(): array
50    {
51        return [
52            'siteKey' => $this->siteKey,
53            'captchaChallenge' => $this->challengeUrl,
54            'captchaVerify' => $this->verifyUrl,
55            'captchaEnabled' => \App::$CAPTCHA_ENABLED
56        ];
57    }
58
59    public function generateToken(): string
60    {
61        $payload = [
62            'ip' => ClientIpHelper::getClientIp(),
63            'iat' => time(),
64            'exp' => time() + $this->tokenExpirationSeconds,
65        ];
66
67        return JWT::encode($payload, $this->tokenSecret, 'HS256');
68    }
69
70    #[\Override]
71    public function createChallenge(): array
72    {
73        try {
74            $response = $this->httpClient->post($this->challengeUrl, [
75                'json' => [
76                    'siteKey' => $this->siteKey,
77                    'siteSecret' => $this->siteSecret,
78                    'clientAddress' => ClientIpHelper::getClientIp(),
79                ]
80            ]);
81
82            $responseData = json_decode((string) $response->getBody(), true);
83
84            if (json_last_error() !== JSON_ERROR_NONE) {
85                throw new \Exception('Error decoding the JSON response');
86            }
87
88            $challenge = $responseData['challenge'] ?? null;
89
90            if ($challenge === null) {
91                throw new \Exception('Missing challenge data');
92            }
93
94            return $challenge;
95        } catch (RequestException $e) {
96            return [
97                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
98                'data' => null,
99            ];
100        } catch (\Throwable $e) {
101            return [
102                'meta' => ['success' => false, 'error' => $e->getMessage()],
103                'data' => null,
104            ];
105        }
106    }
107
108    #[\Override]
109    public function verifySolution(?string $payload): array
110    {
111        if (!$payload) {
112            return [
113                'meta' => ['success' => false, 'error' => 'No payload provided'],
114                'data' => null,
115            ];
116        }
117
118        $decodedJson = base64_decode(strtr($payload, '-_', '+/'));
119        if (!$decodedJson) {
120            return [
121                'meta' => ['success' => false, 'error' => 'Payload could not be decoded'],
122                'data' => null,
123            ];
124        }
125
126        $decodedPayload = json_decode($decodedJson, true);
127        if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPayload)) {
128            return [
129                'meta' => ['success' => false, 'error' => 'Invalid JSON in payload'],
130                'data' => null,
131            ];
132        }
133
134        try {
135            $response = $this->httpClient->post($this->verifyUrl, [
136                'json' => [
137                    'siteKey' => $this->siteKey,
138                    'siteSecret' => $this->siteSecret,
139                    'clientAddress' => ClientIpHelper::getClientIp(),
140                    'payload' => $decodedPayload,
141                ]
142            ]);
143
144            $responseData = json_decode((string) $response->getBody(), true);
145
146            if (json_last_error() !== JSON_ERROR_NONE || !is_array($responseData)) {
147                throw new \Exception('Response from Captcha service is not valid JSON');
148            }
149
150            if (!array_key_exists('valid', $responseData)) {
151                return [
152                    'meta' => ['success' => false, 'error' => 'Response does not contain a "valid" field'],
153                    'data' => $responseData,
154                ];
155            }
156
157            if ($responseData['valid'] !== true) {
158                return [
159                    'meta' => ['success' => false, 'error' => 'Captcha verification failed'],
160                    'data' => $responseData,
161                ];
162            }
163
164            return [
165                'meta' => ['success' => true],
166                'data' => $responseData,
167                'token' => $this->generateToken(),
168            ];
169        } catch (RequestException $e) {
170            return [
171                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
172                'data' => null,
173            ];
174        } catch (\Throwable $e) {
175            return [
176                'meta' => ['success' => false, 'error' => $e->getMessage()],
177                'data' => null,
178            ];
179        }
180    }
181}