Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
87.00% |
87 / 100 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
CaptchaService | |
87.00% |
87 / 100 |
|
50.00% |
3 / 6 |
21.97 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
ensureValid | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
getCaptchaDetails | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
generateToken | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
createChallenge | |
83.33% |
20 / 24 |
|
0.00% |
0 / 1 |
5.12 | |||
verifySolution | |
84.91% |
45 / 53 |
|
0.00% |
0 / 1 |
11.42 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace BO\Zmscitizenapi\Services\Captcha; |
6 | |
7 | use BO\Zmscitizenapi\Helper\ClientIpHelper; |
8 | use BO\Zmscitizenapi\Models\CaptchaInterface; |
9 | use BO\Zmsentities\Schema\Entity; |
10 | use Firebase\JWT\JWT; |
11 | use GuzzleHttp\Client; |
12 | use GuzzleHttp\Exception\RequestException; |
13 | |
14 | class 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 | public function getCaptchaDetails(): array |
62 | { |
63 | return [ |
64 | 'siteKey' => $this->siteKey, |
65 | 'captchaChallenge' => $this->challengeUrl, |
66 | 'captchaVerify' => $this->verifyUrl, |
67 | 'captchaEnabled' => \App::$CAPTCHA_ENABLED |
68 | ]; |
69 | } |
70 | |
71 | /** |
72 | * Generate a JWT for captcha validation. |
73 | * |
74 | * @return string |
75 | */ |
76 | public function generateToken(): string |
77 | { |
78 | $payload = [ |
79 | 'ip' => ClientIpHelper::getClientIp(), |
80 | 'iat' => time(), |
81 | 'exp' => time() + $this->tokenExpirationSeconds, |
82 | ]; |
83 | |
84 | return JWT::encode($payload, $this->tokenSecret, 'HS256'); |
85 | } |
86 | |
87 | /** |
88 | * Request a new captcha challenge. |
89 | * |
90 | * @return array |
91 | * @throws \Exception |
92 | */ |
93 | public function createChallenge(): array |
94 | { |
95 | try { |
96 | $response = $this->httpClient->post($this->challengeUrl, [ |
97 | 'json' => [ |
98 | 'siteKey' => $this->siteKey, |
99 | 'siteSecret' => $this->siteSecret, |
100 | 'clientAddress' => ClientIpHelper::getClientIp(), |
101 | ] |
102 | ]); |
103 | |
104 | $responseData = json_decode((string) $response->getBody(), true); |
105 | |
106 | if (json_last_error() !== JSON_ERROR_NONE) { |
107 | throw new \Exception('Error decoding the JSON response'); |
108 | } |
109 | |
110 | $challenge = $responseData['challenge'] ?? null; |
111 | |
112 | if ($challenge === null) { |
113 | throw new \Exception('Missing challenge data'); |
114 | } |
115 | |
116 | return $challenge; |
117 | } catch (RequestException $e) { |
118 | return [ |
119 | 'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()], |
120 | 'data' => null, |
121 | ]; |
122 | } catch (\Throwable $e) { |
123 | return [ |
124 | 'meta' => ['success' => false, 'error' => $e->getMessage()], |
125 | 'data' => null, |
126 | ]; |
127 | } |
128 | } |
129 | |
130 | /** |
131 | * Verify the captcha solution. |
132 | * |
133 | * @param string $payload |
134 | * @return mixed |
135 | * @throws \Exception |
136 | */ |
137 | public function verifySolution(?string $payload): array |
138 | { |
139 | if (!$payload) { |
140 | return [ |
141 | 'meta' => ['success' => false, 'error' => 'No payload provided'], |
142 | 'data' => null, |
143 | ]; |
144 | } |
145 | |
146 | $decodedJson = base64_decode(strtr($payload, '-_', '+/')); |
147 | if (!$decodedJson) { |
148 | return [ |
149 | 'meta' => ['success' => false, 'error' => 'Payload could not be decoded'], |
150 | 'data' => null, |
151 | ]; |
152 | } |
153 | |
154 | $decodedPayload = json_decode($decodedJson, true); |
155 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPayload)) { |
156 | return [ |
157 | 'meta' => ['success' => false, 'error' => 'Invalid JSON in payload'], |
158 | 'data' => null, |
159 | ]; |
160 | } |
161 | |
162 | try { |
163 | $response = $this->httpClient->post($this->verifyUrl, [ |
164 | 'json' => [ |
165 | 'siteKey' => $this->siteKey, |
166 | 'siteSecret' => $this->siteSecret, |
167 | 'clientAddress' => ClientIpHelper::getClientIp(), |
168 | 'payload' => $decodedPayload, |
169 | ] |
170 | ]); |
171 | |
172 | $responseData = json_decode((string) $response->getBody(), true); |
173 | |
174 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($responseData)) { |
175 | throw new \Exception('Response from Captcha service is not valid JSON'); |
176 | } |
177 | |
178 | if (!array_key_exists('valid', $responseData)) { |
179 | return [ |
180 | 'meta' => ['success' => false, 'error' => 'Response does not contain a "valid" field'], |
181 | 'data' => $responseData, |
182 | ]; |
183 | } |
184 | |
185 | if ($responseData['valid'] !== true) { |
186 | return [ |
187 | 'meta' => ['success' => false, 'error' => 'Captcha verification failed'], |
188 | 'data' => $responseData, |
189 | ]; |
190 | } |
191 | |
192 | return [ |
193 | 'meta' => ['success' => true], |
194 | 'data' => $responseData, |
195 | 'token' => $this->generateToken(), |
196 | ]; |
197 | } catch (RequestException $e) { |
198 | return [ |
199 | 'meta' => ['success' => false, 'error' => 'Request error: ' . $e->getMessage()], |
200 | 'data' => null, |
201 | ]; |
202 | } catch (\Throwable $e) { |
203 | return [ |
204 | 'meta' => ['success' => false, 'error' => $e->getMessage()], |
205 | 'data' => null, |
206 | ]; |
207 | } |
208 | } |
209 | } |