Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.20% covered (success)
93.20%
96 / 103
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MfaApiController
93.14% covered (success)
93.14%
95 / 102
50.00% covered (danger)
50.00%
5 / 10
36.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listDevices
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 enrollDevice
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 confirmDevice
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 executeEnrollmentConfirmation
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 revokeDevice
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 executeRevocation
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 setDefaultDevice
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 resolveCurrentUserId
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 parsePayload
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\StepUp\ReAuthenticationServiceInterface;
12use App\Modules\User\Application\Service\UserMfaDeviceServiceInterface;
13use App\Shared\Infrastructure\Http\ApiResponseTrait;
14use Nyholm\Psr7\Response;
15use Psr\Http\Message\ResponseFactoryInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use Yiisoft\Session\SessionInterface;
19use Yiisoft\User\CurrentUser;
20
21/**
22 * MFA Device Management REST API Controller.
23 *
24 * Provides endpoints for listing registered MFA devices, initiating setup enrollment,
25 * verifying enrollment codes, and revoking authenticator tokens with Step-Up reauthentication.
26 *
27 * @package App\Modules\User\Presentation\Api
28 */
29final readonly class MfaApiController
30{
31    use ApiResponseTrait;
32
33    private const ERROR_UNAUTHORIZED = 'Unauthorized access';
34
35    /**
36     * MfaApiController constructor.
37     *
38     * @param ResponseFactoryInterface              $responseFactory PSR-7 response factory.
39     * @param UserMfaDeviceServiceInterface         $mfaService      MFA device application service.
40     * @param CurrentUser                           $currentUser     Current authenticated user service.
41     * @param SessionInterface|null                 $session         Optional active session.
42     * @param ReAuthenticationServiceInterface|null $reAuthService   Optional re-authentication service.
43     */
44    public function __construct(
45        private ResponseFactoryInterface $responseFactory,
46        private UserMfaDeviceServiceInterface $mfaService,
47        private CurrentUser $currentUser,
48        private ?SessionInterface $session = null,
49        private ?ReAuthenticationServiceInterface $reAuthService = null
50    ) {
51    }
52
53    /**
54     * Lists registered MFA devices for currently authenticated user.
55     *
56     * @param ServerRequestInterface $request PSR-7 server request.
57     * @return ResponseInterface PSR-7 JSON response.
58     */
59    public function listDevices(ServerRequestInterface $request): ResponseInterface
60    {
61        $userId = $this->resolveCurrentUserId($request);
62        if ($userId === null) {
63            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
64        }
65
66        $devices = $this->mfaService->getUserDevices($userId);
67        return $this->buildJsonResponse($this->responseFactory, [
68            'status'  => true,
69            'success' => true,
70            'data'    => [
71                'has_active_mfa' => $this->mfaService->hasActiveMfa($userId),
72                'devices'        => $devices,
73            ],
74        ]);
75    }
76
77    /**
78     * Starts enrollment for new authenticator device, generating Base32 secret and SVG QR code.
79     *
80     * @param ServerRequestInterface $request PSR-7 server request.
81     * @return ResponseInterface PSR-7 JSON response.
82     */
83    public function enrollDevice(ServerRequestInterface $request): ResponseInterface
84    {
85        $userId = $this->resolveCurrentUserId($request);
86        if ($userId === null) {
87            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
88        }
89
90        $body = $this->parsePayload($request);
91        $deviceName = (string) ($body['device_name'] ?? 'Google Authenticator');
92
93        $sessionData = $this->mfaService->startEnrollment($userId, $deviceName);
94        return $this->buildJsonResponse($this->responseFactory, [
95            'status'  => true,
96            'success' => true,
97            'data'    => $sessionData,
98        ]);
99    }
100
101    /**
102     * Confirms and persists new MFA device after verifying first TOTP code.
103     *
104     * @param ServerRequestInterface $request PSR-7 server request.
105     * @return ResponseInterface PSR-7 JSON response.
106     */
107    public function confirmDevice(ServerRequestInterface $request): ResponseInterface
108    {
109        $userId = $this->resolveCurrentUserId($request);
110        if ($userId === null) {
111            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
112        }
113
114        $body = $this->parsePayload($request);
115        return $this->executeEnrollmentConfirmation($userId, $body);
116    }
117
118    /**
119     * @param array<string, mixed> $body
120     */
121    private function executeEnrollmentConfirmation(int $userId, array $body): ResponseInterface
122    {
123        $deviceName = (string) ($body['device_name'] ?? 'Google Authenticator');
124        $secret = (string) ($body['secret'] ?? '');
125        $code = (string) ($body['code'] ?? '');
126
127        if ($secret === '' || $code === '') {
128            return $this->jsonError($this->responseFactory, 'Missing required verification parameters.', 400);
129        }
130
131        $result = $this->mfaService->confirmEnrollment($userId, $deviceName, $secret, $code);
132        if (!($result['success'] ?? false)) {
133            $errorMsg = (string) ($result['message'] ?? 'Invalid TOTP verification code.');
134            return $this->jsonError($this->responseFactory, $errorMsg, 400);
135        }
136
137        return $this->buildJsonResponse($this->responseFactory, [
138            'status'  => true,
139            'success' => true,
140            'message' => 'Device has been successfully registered.',
141            'data'    => [
142                'recovery_codes' => $result['recovery_codes'] ?? null,
143            ],
144        ]);
145    }
146
147    /**
148     * Revokes specified MFA device.
149     *
150     * @param ServerRequestInterface $request PSR-7 server request.
151     * @return ResponseInterface PSR-7 JSON response.
152     */
153    public function revokeDevice(ServerRequestInterface $request): ResponseInterface
154    {
155        $userId = $this->resolveCurrentUserId($request);
156        if ($userId === null) {
157            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
158        }
159
160        $body = $this->parsePayload($request);
161        $deviceId = (int) ($body['device_id'] ?? 0);
162        $password = (string) ($body['password'] ?? '');
163
164        if ($this->reAuthService !== null && $this->session !== null) {
165            $isFresh = $password !== ''
166                ? $this->reAuthService->verifyAndRefresh($userId, $password, $this->session)
167                : $this->reAuthService->hasFreshAuth($this->session);
168
169            if (!$isFresh) {
170                return $this->jsonError(
171                    $this->responseFactory,
172                    'Re-authentication required: provide valid password to revoke MFA device.',
173                    403
174                );
175            }
176        }
177
178        return $this->executeRevocation($userId, $deviceId);
179    }
180
181    private function executeRevocation(int $userId, int $deviceId): ResponseInterface
182    {
183        if ($deviceId <= 0) {
184            return $this->jsonError($this->responseFactory, 'Invalid device identifier.', 400);
185        }
186
187        $success = $this->mfaService->revokeDevice($userId, $deviceId);
188        if (!$success) {
189            return $this->jsonError($this->responseFactory, 'Failed to revoke MFA token.', 400);
190        }
191
192        return $this->buildJsonResponse($this->responseFactory, [
193            'status'  => true,
194            'success' => true,
195            'message' => 'MFA token has been successfully revoked.',
196        ]);
197    }
198
199    /**
200     * Sets specified MFA device as primary default authenticator.
201     *
202     * @param ServerRequestInterface $request PSR-7 server request.
203     * @return ResponseInterface PSR-7 JSON response.
204     */
205    public function setDefaultDevice(ServerRequestInterface $request): ResponseInterface
206    {
207        $userId = $this->resolveCurrentUserId($request);
208        if ($userId === null) {
209            return $this->jsonError($this->responseFactory, self::ERROR_UNAUTHORIZED, 401);
210        }
211
212        $body = $this->parsePayload($request);
213        $deviceId = (int) ($body['device_id'] ?? 0);
214        if ($deviceId <= 0 || !$this->mfaService->setDefaultDevice($userId, $deviceId)) {
215            return $this->jsonError($this->responseFactory, 'Failed to set default device.', 400);
216        }
217
218        return $this->buildJsonResponse($this->responseFactory, [
219            'status'  => true,
220            'success' => true,
221            'message' => 'Device has been set as default.',
222        ]);
223    }
224
225
226    /**
227     * Resolves authenticated user ID from CurrentUser service or request attribute.
228     */
229    private function resolveCurrentUserId(ServerRequestInterface $request): ?int
230    {
231        if (!$this->currentUser->isGuest()) {
232            $identity = $this->currentUser->getIdentity();
233            $id = $identity->getId();
234            if ($id !== null && is_numeric($id) && (int) $id > 0) {
235                return (int) $id;
236            }
237        }
238
239        $attrId = $request->getAttribute('user_id');
240        if (is_numeric($attrId) && (int) $attrId > 0) {
241            return (int) $attrId;
242        }
243
244        return null;
245    }
246
247    /**
248     * Extracts parsed array payload from PSR-7 request body supporting JSON and form data.
249     *
250     * @param ServerRequestInterface $request PSR-7 server request.
251     * @return array<string, mixed> Request parameters array.
252     */
253    private function parsePayload(ServerRequestInterface $request): array
254    {
255        $body = $request->getParsedBody();
256        if (is_array($body) && !empty($body)) {
257            return $body;
258        }
259
260        $raw = (string) $request->getBody();
261        if ($raw === '') {
262            return [];
263        }
264
265        $decoded = json_decode($raw, true);
266        return is_array($decoded) ? $decoded : [];
267    }
268}