Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.11% covered (warning)
81.11%
73 / 90
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CaptchaService
81.11% covered (warning)
81.11%
73 / 90
50.00% covered (danger)
50.00%
3 / 6
18.95
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
72.09% covered (warning)
72.09%
31 / 43
0.00% covered (danger)
0.00%
0 / 1
8.06
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Captcha;
6
7use BO\Zmscitizenapi\Helper\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     * Gibt die Captcha-Konfigurationsdetails zurück.
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     * Generiert einen JWT für die Captcha-Validierung.
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     * Fordert eine neue Captcha-Challenge an.
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('Fehler beim Dekodieren der JSON-Antwort');
108            }
109
110            $challenge = $responseData['challenge'] ?? null;
111
112            if ($challenge === null) {
113                throw new \Exception('Challenge-Daten fehlen in der Antwort');
114            }
115
116            return $challenge;
117        } catch (RequestException $e) {
118            return [
119                'meta' => ['success' => false, 'error' => 'Request-Fehler: ' . $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     * Überprüft die Captcha-Lösung.
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' => 'Keine Payload übergeben'],
142                'data' => null,
143            ];
144        }
145
146        $decodedJson = base64_decode(strtr($payload, '-_', '+/'));
147        if (!$decodedJson) {
148            return [
149                'meta' => ['success' => false, 'error' => 'Payload konnte nicht dekodiert werden'],
150                'data' => null,
151            ];
152        }
153
154        $decodedPayload = json_decode($decodedJson, true);
155        if (json_last_error() !== JSON_ERROR_NONE) {
156            return [
157                'meta' => ['success' => false, 'error' => 'Ungültiges 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) {
175                throw new \Exception('Antwort vom Captcha-Service ist kein gültiges JSON');
176            }
177
178            return [
179                'meta' => ['success' => true],
180                'data' => $responseData,
181                'token' => $this->generateToken(),
182            ];
183        } catch (RequestException $e) {
184            return [
185                'meta' => ['success' => false, 'error' => 'Request-Fehler: ' . $e->getMessage()],
186                'data' => null,
187            ];
188        } catch (\Throwable $e) {
189            return [
190                'meta' => ['success' => false, 'error' => $e->getMessage()],
191                'data' => null,
192            ];
193        }
194    }
195}