Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.02% covered (warning)
79.02%
324 / 410
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthController
79.02% covered (warning)
79.02%
324 / 410
60.00% covered (warning)
60.00%
9 / 15
112.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 login
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
8
 logout
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 refresh
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 getSessions
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 revokeSession
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getActivity
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getMfaStatus
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 setupMfa
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
5.26
 enableMfa
76.74% covered (warning)
76.74%
33 / 43
0.00% covered (danger)
0.00%
0 / 1
8.80
 disableMfa
76.19% covered (warning)
76.19%
32 / 42
0.00% covered (danger)
0.00%
0 / 1
8.86
 verifyMfa
38.10% covered (danger)
38.10%
24 / 63
0.00% covered (danger)
0.00%
0 / 1
14.54
 regenerateBackupCodes
76.74% covered (warning)
76.74%
33 / 43
0.00% covered (danger)
0.00%
0 / 1
8.80
 getJsonBody
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 jsonResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Authentication Controller
4 *
5 * Handles modern OAuth2-style authentication endpoints for JWT-based auth.
6 * Works alongside existing session-based authentication.
7 *
8 * @package BuyerKiosk\Auth\Controllers
9 */
10
11namespace BuyerKiosk\Auth\Controllers;
12
13use BuyerKiosk\Auth\Services\AuthService;
14use BuyerKiosk\Auth\Services\TokenService;
15use BuyerKiosk\Auth\Services\AuditLogger;
16use BuyerKiosk\Auth\Services\RateLimiter;
17use BuyerKiosk\Auth\Services\MfaService;
18
19class AuthController
20{
21    /**
22     * @var \Slim\Slim Slim application instance
23     */
24    protected $app;
25
26    /**
27     * @var AuthService Authentication service
28     */
29    protected $authService;
30
31    /**
32     * @var TokenService Token service
33     */
34    protected $tokenService;
35
36    /**
37     * @var AuditLogger Audit logger
38     */
39    protected $auditLogger;
40
41    /**
42     * @var RateLimiter Rate limiter
43     */
44    protected $rateLimiter;
45
46    /**
47     * @var MfaService MFA service
48     */
49    protected $mfaService;
50
51    /**
52     * @var \PDO Database connection
53     */
54    protected $db;
55
56    /**
57     * Constructor
58     *
59     * @param \Slim\Slim $app Slim application instance
60     */
61    public function __construct(\Slim\Slim $app)
62    {
63        $this->app = $app;
64        $this->db = dbConnectByName('kiosk_users');
65        $this->tokenService = new TokenService($this->db);
66        $this->auditLogger = new AuditLogger($this->db);
67        $this->rateLimiter = new RateLimiter($this->db);
68        $this->authService = new AuthService(
69            $this->db,
70            $this->tokenService,
71            $this->auditLogger,
72            $this->rateLimiter
73        );
74        $this->mfaService = new MfaService($this->db, $this->auditLogger);
75    }
76
77    /**
78     * POST /auth/login
79     *
80     * Authenticate user and return JWT tokens
81     */
82    public function login(): void
83    {
84        $request = $this->app->request;
85
86        // Rate limit check (per-IP)
87        $clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
88        $rateResult = $this->rateLimiter->checkLimit('login', $clientIp);
89
90        if ($rateResult->isBlocked()) {
91            $this->jsonResponse(429, [
92                'error' => 'rate_limited',
93                'message' => 'Too many login attempts',
94                'retry_after' => $rateResult->getRetryAfter()
95            ]);
96            return;
97        }
98
99        // Parse request body
100        $body = $this->getJsonBody();
101
102        if (empty($body['username']) || empty($body['password'])) {
103            $this->jsonResponse(400, [
104                'error' => 'invalid_request',
105                'message' => 'Username and password are required'
106            ]);
107            return;
108        }
109
110        // Collect device info
111        $deviceInfo = [
112            'ip' => $clientIp,
113            'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
114            'deviceName' => $body['device_name'] ?? null,
115            'fingerprint' => $body['device_fingerprint'] ?? null
116        ];
117
118        // Attempt authentication
119        $result = $this->authService->login(
120            $body['username'],
121            $body['password'],
122            $body['mfa_code'] ?? null,
123            $deviceInfo
124        );
125
126        if ($result->isSuccess()) {
127            // Clear IP rate limit on success
128            $this->rateLimiter->clearAttempts('login', $clientIp);
129
130            $this->jsonResponse(200, [
131                'access_token' => $result->getData()['accessToken'],
132                'refresh_token' => $result->getData()['refreshToken'],
133                'token_type' => 'Bearer',
134                'expires_in' => $result->getData()['expiresIn'],
135                'user' => $result->getData()['user'],
136                'stores' => $result->getData()['stores']
137            ]);
138            return;
139        }
140
141        // Record failure for rate limiting
142        $this->rateLimiter->recordFailure('login', $clientIp);
143
144        // Handle specific error cases
145        if ($result->isMfaRequired()) {
146            $this->jsonResponse(200, [
147                'mfa_required' => true,
148                'message' => 'Please provide MFA code'
149            ]);
150            return;
151        }
152
153        if ($result->isRateLimited()) {
154            $this->jsonResponse(429, [
155                'error' => 'rate_limited',
156                'message' => $result->getError(),
157                'retry_after' => $result->getRetryAfter()
158            ]);
159            return;
160        }
161
162        if ($result->isLocked()) {
163            $this->jsonResponse(403, [
164                'error' => 'account_locked',
165                'message' => $result->getError(),
166                'retry_after' => $result->getRetryAfter()
167            ]);
168            return;
169        }
170
171        // Generic authentication failure
172        $this->jsonResponse(401, [
173            'error' => 'invalid_credentials',
174            'message' => 'Invalid username or password'
175        ]);
176    }
177
178    /**
179     * POST /auth/logout
180     *
181     * Revoke tokens and log out user
182     */
183    public function logout(): void
184    {
185        $request = $this->app->request;
186        $body = $this->getJsonBody();
187
188        // Get user from auth context (set by middleware)
189        $authContext = $this->app->authContext ?? null;
190
191        if (!$authContext || empty($authContext['user_id'])) {
192            $this->jsonResponse(401, [
193                'error' => 'unauthorized',
194                'message' => 'Not authenticated'
195            ]);
196            return;
197        }
198
199        $userId = (int)$authContext['user_id'];
200        $refreshToken = $body['refresh_token'] ?? null;
201        $allDevices = !empty($body['all_devices']);
202
203        $this->authService->logout($userId, $refreshToken, $allDevices);
204
205        $this->jsonResponse(200, [
206            'success' => true,
207            'message' => $allDevices ? 'Logged out from all devices' : 'Logged out successfully'
208        ]);
209    }
210
211    /**
212     * POST /auth/refresh
213     *
214     * Refresh access token using refresh token
215     */
216    public function refresh(): void
217    {
218        $body = $this->getJsonBody();
219
220        if (empty($body['refresh_token'])) {
221            $this->jsonResponse(400, [
222                'error' => 'invalid_request',
223                'message' => 'Refresh token is required'
224            ]);
225            return;
226        }
227
228        $deviceInfo = [
229            'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
230            'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? null
231        ];
232
233        $result = $this->authService->refreshToken($body['refresh_token'], $deviceInfo);
234
235        if (!$result->isSuccess()) {
236            $this->jsonResponse(401, [
237                'error' => 'invalid_token',
238                'message' => 'Invalid or expired refresh token'
239            ]);
240            return;
241        }
242
243        $this->jsonResponse(200, [
244            'access_token' => $result->getData()['accessToken'],
245            'refresh_token' => $result->getData()['refreshToken'],
246            'token_type' => 'Bearer',
247            'expires_in' => $result->getData()['expiresIn']
248        ]);
249    }
250
251    /**
252     * GET /auth/sessions
253     *
254     * Get list of active sessions for current user
255     */
256    public function getSessions(): void
257    {
258        $authContext = $this->app->authContext ?? null;
259
260        if (!$authContext || empty($authContext['user_id'])) {
261            $this->jsonResponse(401, ['error' => 'unauthorized']);
262            return;
263        }
264
265        $userId = (int)$authContext['user_id'];
266        $sessions = $this->tokenService->getUserActiveTokens($userId);
267
268        $this->jsonResponse(200, [
269            'sessions' => array_map(function($session) {
270                return [
271                    'id' => $session['id'],
272                    'device_name' => $session['deviceName'],
273                    'ip_address' => $session['ipAddress'],
274                    'created_at' => $session['createdAt'],
275                    'last_used' => $session['lastUsedAt'],
276                    'expires_at' => $session['expiresAt']
277                ];
278            }, $sessions)
279        ]);
280    }
281
282    /**
283     * DELETE /auth/sessions/:id
284     *
285     * Revoke a specific session
286     */
287    public function revokeSession(int $sessionId): void
288    {
289        $authContext = $this->app->authContext ?? null;
290
291        if (!$authContext || empty($authContext['user_id'])) {
292            $this->jsonResponse(401, ['error' => 'unauthorized']);
293            return;
294        }
295
296        $userId = (int)$authContext['user_id'];
297        $revoked = $this->tokenService->revokeTokenById($userId, $sessionId);
298
299        if (!$revoked) {
300            $this->jsonResponse(404, [
301                'error' => 'not_found',
302                'message' => 'Session not found'
303            ]);
304            return;
305        }
306
307        $this->auditLogger->logTokenRevoked($userId, 'manual');
308
309        $this->jsonResponse(200, [
310            'success' => true,
311            'message' => 'Session revoked'
312        ]);
313    }
314
315    /**
316     * GET /auth/activity
317     *
318     * Get recent authentication activity for current user
319     */
320    public function getActivity(): void
321    {
322        $authContext = $this->app->authContext ?? null;
323
324        if (!$authContext || empty($authContext['user_id'])) {
325            $this->jsonResponse(401, ['error' => 'unauthorized']);
326            return;
327        }
328
329        $userId = (int)$authContext['user_id'];
330        $events = $this->auditLogger->getRecentEvents($userId, 20);
331
332        $this->jsonResponse(200, [
333            'events' => array_map(function($event) {
334                return [
335                    'type' => $event['eventType'],
336                    'ip_address' => $event['ipAddress'],
337                    'timestamp' => $event['createdAt'],
338                    'details' => $event['details']
339                ];
340            }, $events)
341        ]);
342    }
343
344    // =========================================================================
345    // MFA Endpoints
346    // =========================================================================
347
348    /**
349     * GET /auth/mfa/status
350     *
351     * Get MFA status for current user
352     */
353    public function getMfaStatus(): void
354    {
355        $authContext = $this->app->authContext ?? null;
356
357        if (!$authContext || empty($authContext['user_id'])) {
358            $this->jsonResponse(401, ['error' => 'unauthorized']);
359            return;
360        }
361
362        $userId = (int)$authContext['user_id'];
363
364        try {
365            $status = $this->mfaService->getStatus($userId);
366            $this->jsonResponse(200, [
367                'mfa' => [
368                    'enabled' => $status['enabled'],
369                    'verified_at' => $status['verifiedAt'],
370                    'backup_codes_remaining' => $status['backupCodesRemaining']
371                ]
372            ]);
373        } catch (\Exception $e) {
374            $this->jsonResponse(500, [
375                'error' => 'server_error',
376                'message' => 'Failed to get MFA status'
377            ]);
378        }
379    }
380
381    /**
382     * GET /auth/mfa/setup
383     *
384     * Start MFA setup - returns QR code and backup codes
385     */
386    public function setupMfa(): void
387    {
388        $authContext = $this->app->authContext ?? null;
389
390        if (!$authContext || empty($authContext['user_id'])) {
391            $this->jsonResponse(401, ['error' => 'unauthorized']);
392            return;
393        }
394
395        $userId = (int)$authContext['user_id'];
396
397        try {
398            $setupData = $this->mfaService->beginSetup($userId);
399
400            $this->jsonResponse(200, [
401                'qr_uri' => $setupData['qrUri'],
402                'secret' => $setupData['secret'],
403                'backup_codes' => $setupData['backupCodes'],
404                'manual_entry' => $setupData['manualEntry'],
405                'message' => 'Scan the QR code with your authenticator app, then verify with a code'
406            ]);
407        } catch (\RuntimeException $e) {
408            $this->jsonResponse(400, [
409                'error' => 'setup_failed',
410                'message' => $e->getMessage()
411            ]);
412        } catch (\Exception $e) {
413            $this->jsonResponse(500, [
414                'error' => 'server_error',
415                'message' => 'Failed to start MFA setup'
416            ]);
417        }
418    }
419
420    /**
421     * POST /auth/mfa/enable
422     *
423     * Enable MFA after verifying first TOTP code
424     */
425    public function enableMfa(): void
426    {
427        $authContext = $this->app->authContext ?? null;
428
429        if (!$authContext || empty($authContext['user_id'])) {
430            $this->jsonResponse(401, ['error' => 'unauthorized']);
431            return;
432        }
433
434        $userId = (int)$authContext['user_id'];
435        $body = $this->getJsonBody();
436
437        if (empty($body['code'])) {
438            $this->jsonResponse(400, [
439                'error' => 'invalid_request',
440                'message' => 'Verification code is required'
441            ]);
442            return;
443        }
444
445        // Rate limit MFA setup attempts
446        $rateResult = $this->rateLimiter->checkLimit('mfa_verify', (string)$userId);
447        if ($rateResult->isBlocked()) {
448            $this->auditLogger->logMfaFailed($userId, 'rate_limited');
449            $this->jsonResponse(429, [
450                'error' => 'rate_limited',
451                'message' => 'Too many MFA verification attempts',
452                'retry_after' => $rateResult->getRetryAfter()
453            ]);
454            return;
455        }
456
457        try {
458            if ($this->mfaService->completeSetup($userId, $body['code'])) {
459                $this->rateLimiter->clearAttempts('mfa_verify', (string)$userId);
460                $this->jsonResponse(200, [
461                    'success' => true,
462                    'message' => 'MFA has been enabled successfully'
463                ]);
464            } else {
465                $this->rateLimiter->recordFailure('mfa_verify', (string)$userId);
466                $this->auditLogger->logMfaFailed($userId, 'invalid_code_on_enable');
467                $this->jsonResponse(400, [
468                    'error' => 'invalid_code',
469                    'message' => 'Invalid verification code. Please try again.'
470                ]);
471            }
472        } catch (\RuntimeException $e) {
473            $this->jsonResponse(400, [
474                'error' => 'enable_failed',
475                'message' => $e->getMessage()
476            ]);
477        } catch (\Exception $e) {
478            $this->jsonResponse(500, [
479                'error' => 'server_error',
480                'message' => 'Failed to enable MFA'
481            ]);
482        }
483    }
484
485    /**
486     * DELETE /auth/mfa/disable
487     *
488     * Disable MFA (requires code verification)
489     */
490    public function disableMfa(): void
491    {
492        $authContext = $this->app->authContext ?? null;
493
494        if (!$authContext || empty($authContext['user_id'])) {
495            $this->jsonResponse(401, ['error' => 'unauthorized']);
496            return;
497        }
498
499        $userId = (int)$authContext['user_id'];
500        $body = $this->getJsonBody();
501
502        if (empty($body['code'])) {
503            $this->jsonResponse(400, [
504                'error' => 'invalid_request',
505                'message' => 'Verification code is required to disable MFA'
506            ]);
507            return;
508        }
509
510        // Rate limit MFA disable attempts
511        $rateResult = $this->rateLimiter->checkLimit('mfa_verify', (string)$userId);
512        if ($rateResult->isBlocked()) {
513            $this->auditLogger->logMfaFailed($userId, 'rate_limited');
514            $this->jsonResponse(429, [
515                'error' => 'rate_limited',
516                'message' => 'Too many MFA verification attempts',
517                'retry_after' => $rateResult->getRetryAfter()
518            ]);
519            return;
520        }
521
522        try {
523            if ($this->mfaService->disable($userId, $body['code'])) {
524                $this->rateLimiter->clearAttempts('mfa_verify', (string)$userId);
525                $this->jsonResponse(200, [
526                    'success' => true,
527                    'message' => 'MFA has been disabled'
528                ]);
529            } else {
530                $this->rateLimiter->recordFailure('mfa_verify', (string)$userId);
531                $this->jsonResponse(400, [
532                    'error' => 'invalid_code',
533                    'message' => 'Invalid verification code'
534                ]);
535            }
536        } catch (\RuntimeException $e) {
537            $this->jsonResponse(400, [
538                'error' => 'disable_failed',
539                'message' => $e->getMessage()
540            ]);
541        } catch (\Exception $e) {
542            $this->jsonResponse(500, [
543                'error' => 'server_error',
544                'message' => 'Failed to disable MFA'
545            ]);
546        }
547    }
548
549    /**
550     * POST /auth/mfa/verify
551     *
552     * Verify MFA code during login (for step-up auth)
553     */
554    public function verifyMfa(): void
555    {
556        $body = $this->getJsonBody();
557
558        if (empty($body['user_id']) || empty($body['code'])) {
559            $this->jsonResponse(400, [
560                'error' => 'invalid_request',
561                'message' => 'User ID and code are required'
562            ]);
563            return;
564        }
565
566        $userId = (int)$body['user_id'];
567
568        // Rate limit MFA verification attempts
569        $rateResult = $this->rateLimiter->checkLimit('mfa_verify', (string)$userId);
570        if ($rateResult->isBlocked()) {
571            $this->auditLogger->logMfaFailed($userId, 'rate_limited');
572            $this->jsonResponse(429, [
573                'error' => 'rate_limited',
574                'message' => 'Too many MFA verification attempts',
575                'retry_after' => $rateResult->getRetryAfter()
576            ]);
577            return;
578        }
579
580        if ($this->mfaService->verifyAnyCode($userId, $body['code'])) {
581            $this->rateLimiter->clearAttempts('mfa_verify', (string)$userId);
582
583            // Load user and generate tokens
584            $stmt = $this->db->prepare("
585                SELECT id, username, email, displayName, accountType
586                FROM users WHERE id = :id
587            ");
588            $stmt->execute(['id' => $userId]);
589            $user = $stmt->fetch(\PDO::FETCH_ASSOC);
590
591            if (!$user) {
592                $this->jsonResponse(401, ['error' => 'user_not_found']);
593                return;
594            }
595
596            // Get stores and permissions for token
597            $storesStmt = $this->db->prepare("
598                SELECT typeNum FROM userStoreAssignments
599                WHERE userId = :userId AND isActive = 1
600            ");
601            $storesStmt->execute(['userId' => $userId]);
602            $stores = $storesStmt->fetchAll(\PDO::FETCH_COLUMN);
603
604            $permsStmt = $this->db->prepare("
605                SELECT DISTINCT ag.hook
606                FROM userGroups ug
607                JOIN uf_authorize_group ag ON ug.groupId = ag.group_id
608                WHERE ug.userId = :userId
609            ");
610            $permsStmt->execute(['userId' => $userId]);
611            $permissions = $permsStmt->fetchAll(\PDO::FETCH_COLUMN);
612
613            $deviceInfo = [
614                'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
615                'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? null
616            ];
617
618            $accessToken = $this->tokenService->createAccessToken($user, $stores, $permissions);
619            $refreshToken = $this->tokenService->createRefreshToken($userId, $deviceInfo);
620
621            $this->auditLogger->logLogin($userId, 'mfa');
622            $this->auditLogger->logTokenIssued($userId, 'access');
623            $this->auditLogger->logTokenIssued($userId, 'refresh');
624
625            $this->jsonResponse(200, [
626                'access_token' => $accessToken,
627                'refresh_token' => $refreshToken,
628                'token_type' => 'Bearer',
629                'expires_in' => $this->tokenService->getAccessTokenTTL(),
630                'user' => [
631                    'id' => (int)$user['id'],
632                    'username' => $user['username'],
633                    'email' => $user['email'],
634                    'displayName' => $user['displayName'],
635                    'accountType' => $user['accountType']
636                ],
637                'stores' => $stores
638            ]);
639        } else {
640            $this->rateLimiter->recordFailure('mfa_verify', (string)$userId);
641            $this->auditLogger->logMfaFailed($userId, 'invalid_code');
642
643            $this->jsonResponse(401, [
644                'error' => 'invalid_code',
645                'message' => 'Invalid MFA code'
646            ]);
647        }
648    }
649
650    /**
651     * POST /auth/mfa/backup-codes
652     *
653     * Regenerate backup codes (requires TOTP verification)
654     */
655    public function regenerateBackupCodes(): void
656    {
657        $authContext = $this->app->authContext ?? null;
658
659        if (!$authContext || empty($authContext['user_id'])) {
660            $this->jsonResponse(401, ['error' => 'unauthorized']);
661            return;
662        }
663
664        $userId = (int)$authContext['user_id'];
665        $body = $this->getJsonBody();
666
667        if (empty($body['code'])) {
668            $this->jsonResponse(400, [
669                'error' => 'invalid_request',
670                'message' => 'Current TOTP code is required to regenerate backup codes'
671            ]);
672            return;
673        }
674
675        // Rate limit
676        $rateResult = $this->rateLimiter->checkLimit('mfa_verify', (string)$userId);
677        if ($rateResult->isBlocked()) {
678            $this->jsonResponse(429, [
679                'error' => 'rate_limited',
680                'retry_after' => $rateResult->getRetryAfter()
681            ]);
682            return;
683        }
684
685        try {
686            $newCodes = $this->mfaService->regenerateBackupCodes($userId, $body['code']);
687
688            if ($newCodes !== null) {
689                $this->rateLimiter->clearAttempts('mfa_verify', (string)$userId);
690                $this->jsonResponse(200, [
691                    'success' => true,
692                    'backup_codes' => $newCodes,
693                    'message' => 'New backup codes generated. Please save them securely.'
694                ]);
695            } else {
696                $this->rateLimiter->recordFailure('mfa_verify', (string)$userId);
697                $this->auditLogger->logMfaFailed($userId, 'invalid_code_on_regenerate');
698                $this->jsonResponse(400, [
699                    'error' => 'invalid_code',
700                    'message' => 'Invalid verification code'
701                ]);
702            }
703        } catch (\RuntimeException $e) {
704            $this->jsonResponse(400, [
705                'error' => 'regenerate_failed',
706                'message' => $e->getMessage()
707            ]);
708        } catch (\Exception $e) {
709            $this->jsonResponse(500, [
710                'error' => 'server_error',
711                'message' => 'Failed to regenerate backup codes'
712            ]);
713        }
714    }
715
716    // =========================================================================
717    // Helper Methods
718    // =========================================================================
719
720    /**
721     * Parse JSON request body
722     *
723     * @return array Parsed body or empty array
724     */
725    private function getJsonBody(): array
726    {
727        $body = $this->app->request->getBody();
728
729        if (empty($body)) {
730            return [];
731        }
732
733        $decoded = json_decode($body, true);
734
735        return is_array($decoded) ? $decoded : [];
736    }
737
738    /**
739     * Send JSON response
740     *
741     * @param int $status HTTP status code
742     * @param array $data Response data
743     */
744    private function jsonResponse(int $status, array $data): void
745    {
746        $this->app->response->status($status);
747        $this->app->response->headers->set('Content-Type', 'application/json');
748        $this->app->response->body(json_encode($data));
749        $this->app->stop();
750    }
751}