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/** @var string */
18    public string $service;
19/** @var string */
20    private string $siteKey;
21/** @var string */
22    private string $siteSecret;
23/** @var string */
24    private string $tokenSecret;
25/** @var int */
26    private int $tokenExpirationSeconds;
27/** @var string */
28    public string $challengeUrl;
29/** @var string */
30    public string $verifyUrl;
31/** @var Client */
32    protected Client $httpClient;
33/**
34     * Constructor.
35     */
36    public function __construct()
37    {
38        $this->service = 'CaptchaService';
39        $this->siteKey = \App::$ALTCHA_CAPTCHA_SITE_KEY;
40        $this->siteSecret = \App::$ALTCHA_CAPTCHA_SITE_SECRET;
41        $this->tokenSecret = \App::$CAPTCHA_TOKEN_SECRET;
42        $this->tokenExpirationSeconds = \App::$CAPTCHA_TOKEN_TTL;
43        $this->challengeUrl = \App::$ALTCHA_CAPTCHA_ENDPOINT_CHALLENGE;
44        $this->verifyUrl = \App::$ALTCHA_CAPTCHA_ENDPOINT_VERIFY;
45        $this->httpClient = new Client(['verify' => false]);
46        $this->ensureValid();
47    }
48
49    private function ensureValid()
50    {
51        if (!$this->testValid()) {
52            throw new \InvalidArgumentException("The provided data is invalid according to the schema.");
53        }
54    }
55
56    /**
57     * Return the captcha configuration details.
58     *
59     * @return array
60     */
61    #[\Override]
62    public function getCaptchaDetails(): array
63    {
64        return [
65            'siteKey' => $this->siteKey,
66            'captchaChallenge' => $this->challengeUrl,
67            'captchaVerify' => $this->verifyUrl,
68            'captchaEnabled' => \App::$CAPTCHA_ENABLED
69        ];
70    }
71
72    /**
73     * Generate a JWT for captcha validation.
74     *
75     * @return string
76     */
77    public function generateToken(): string
78    {
79        $payload = [
80            'ip' => ClientIpHelper::getClientIp(),
81            'iat' => time(),
82            'exp' => time() + $this->tokenExpirationSeconds,
83        ];
84
85        return JWT::encode($payload, $this->tokenSecret, 'HS256');
86    }
87
88    /**
89     * Request a new captcha challenge.
90     *
91     * @return array
92     * @throws \Exception
93     */
94    #[\Override]
95    public function createChallenge(): array
96    {
97        try {
98            $response = $this->httpClient->post($this->challengeUrl, [
99                'json' => [
100                    'siteKey' => $this->siteKey,
101                    'siteSecret' => $this->siteSecret,
102                    'clientAddress' => ClientIpHelper::getClientIp(),
103                ]
104            ]);
105
106            $responseData = json_decode((string) $response->getBody(), true);
107
108            if (json_last_error() !== JSON_ERROR_NONE) {
109                throw new \Exception('Error decoding the JSON response');
110            }
111
112            $challenge = $responseData['challenge'] ?? null;
113
114            if ($challenge === null) {
115                throw new \Exception('Missing challenge data');
116            }
117
118            return $challenge;
119        } catch (RequestException $e) {
120            return [
121                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
122                'data' => null,
123            ];
124        } catch (\Throwable $e) {
125            return [
126                'meta' => ['success' => false, 'error' => $e->getMessage()],
127                'data' => null,
128            ];
129        }
130    }
131
132    /**
133     * Verify the captcha solution.
134     *
135     * @param string $payload
136     * @return mixed
137     * @throws \Exception
138     */
139    #[\Override]
140    public function verifySolution(?string $payload): array
141    {
142        if (!$payload) {
143            return [
144                'meta' => ['success' => false, 'error' => 'No payload provided'],
145                'data' => null,
146            ];
147        }
148
149        $decodedJson = base64_decode(strtr($payload, '-_', '+/'));
150        if (!$decodedJson) {
151            return [
152                'meta' => ['success' => false, 'error' => 'Payload could not be decoded'],
153                'data' => null,
154            ];
155        }
156
157        $decodedPayload = json_decode($decodedJson, true);
158        if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPayload)) {
159            return [
160                'meta' => ['success' => false, 'error' => 'Invalid JSON in payload'],
161                'data' => null,
162            ];
163        }
164
165        try {
166            $response = $this->httpClient->post($this->verifyUrl, [
167                'json' => [
168                    'siteKey' => $this->siteKey,
169                    'siteSecret' => $this->siteSecret,
170                    'clientAddress' => ClientIpHelper::getClientIp(),
171                    'payload' => $decodedPayload,
172                ]
173            ]);
174
175            $responseData = json_decode((string) $response->getBody(), true);
176
177            if (json_last_error() !== JSON_ERROR_NONE || !is_array($responseData)) {
178                throw new \Exception('Response from Captcha service is not valid JSON');
179            }
180
181            if (!array_key_exists('valid', $responseData)) {
182                return [
183                    'meta' => ['success' => false, 'error' => 'Response does not contain a "valid" field'],
184                    'data' => $responseData,
185                ];
186            }
187
188            if ($responseData['valid'] !== true) {
189                return [
190                    'meta' => ['success' => false, 'error' => 'Captcha verification failed'],
191                    'data' => $responseData,
192                ];
193            }
194
195            return [
196                'meta' => ['success' => true],
197                'data' => $responseData,
198                'token' => $this->generateToken(),
199            ];
200        } catch (RequestException $e) {
201            return [
202                'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()],
203                'data' => null,
204            ];
205        } catch (\Throwable $e) {
206            return [
207                'meta' => ['success' => false, 'error' => $e->getMessage()],
208                'data' => null,
209            ];
210        }
211    }
212}