Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
ApiTokenMiddleware
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
8 / 8
27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 authenticateRequest
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 authenticateSession
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 resolveSessionUserId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 createUnauthorizedResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 extractToken
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getSystemApiToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
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\Core\Api\Middleware;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Domain\Repository\ApiKeyRepositoryInterface;
12use App\Core\Audit\Application\Service\SecurityAuditLogger;
13use App\Core\Settings\SqlSettingsRepository;
14use App\Shared\Infrastructure\Http\ApiResponseTrait;
15use Psr\Http\Message\ResponseFactoryInterface;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use Psr\Http\Server\MiddlewareInterface;
19use Psr\Http\Server\RequestHandlerInterface;
20use Yiisoft\Session\SessionInterface;
21use Yiisoft\User\CurrentUser;
22
23/**
24 * REST API Bearer Token Authentication Middleware.
25 *
26 * Validates Authorization Bearer header or api_token query param for /api/v1/* routes
27 * supporting both system-level master token and scoped multi-tenant API keys (NIST AC-6 / OWASP ASVS V2.10).
28 * Also falls back to active authenticated web sessions (Yii3 CurrentUser / SessionInterface) for same-origin AJAX.
29 *
30 * @package App\Core\Api\Middleware
31 */
32final readonly class ApiTokenMiddleware implements MiddlewareInterface
33{
34    use ApiResponseTrait;
35
36    /**
37     * ApiTokenMiddleware constructor.
38     *
39     * @param ResponseFactoryInterface        $responseFactory   PSR-7 Response factory.
40     * @param SqlSettingsRepository           $settingsRepository Settings repository.
41     * @param ApiKeyRepositoryInterface|null $apiKeyRepository   Optional API key repository.
42     * @param SecurityAuditLogger|null        $securityLogger    Optional security audit logger.
43     * @param CurrentUser|null                $currentUser       Optional Yii3 CurrentUser service.
44     * @param SessionInterface|null           $session           Optional Yii3 Session instance.
45     */
46    public function __construct(
47        private ResponseFactoryInterface   $responseFactory,
48        private SqlSettingsRepository      $settingsRepository,
49        private ?ApiKeyRepositoryInterface $apiKeyRepository = null,
50        private ?SecurityAuditLogger       $securityLogger = null,
51        private ?CurrentUser               $currentUser = null,
52        private ?SessionInterface          $session = null,
53    ) {
54    }
55
56    /**
57     * Processes request and authenticates Bearer token or web session.
58     *
59     * @param ServerRequestInterface  $request Server request.
60     * @param RequestHandlerInterface $handler Request handler.
61     * @return ResponseInterface PSR-7 response.
62     */
63    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
64    {
65        $token = $this->extractToken($request);
66
67        if ($token !== '') {
68            $authenticatedRequest = $this->authenticateRequest($request, $token);
69            if ($authenticatedRequest !== null) {
70                return $handler->handle($authenticatedRequest);
71            }
72        }
73
74        $sessionAuthRequest = $this->authenticateSession($request);
75        if ($sessionAuthRequest !== null) {
76            return $handler->handle($sessionAuthRequest);
77        }
78
79        $this->securityLogger?->logUnauthorizedAccess(
80            'Unauthorized: Invalid or missing API Bearer token',
81            $request
82        );
83
84        return $this->createUnauthorizedResponse();
85    }
86
87    /**
88     * Attempts to authenticate request against system token or ApiKey repository.
89     *
90     * @param ServerRequestInterface $request Incoming HTTP request.
91     * @param string                 $token   Raw Bearer token.
92     * @return ServerRequestInterface|null Authenticated request with attributes or null.
93     */
94    private function authenticateRequest(ServerRequestInterface $request, string $token): ?ServerRequestInterface
95    {
96        $expectedSystemToken = $this->getSystemApiToken();
97        if ($expectedSystemToken !== '' && hash_equals($expectedSystemToken, $token)) {
98            return $request
99                ->withAttribute('auth_type', 'token')
100                ->withAttribute('user', [
101                    'id'           => 1,
102                    'username'     => 'system',
103                    'email'        => 'system@internal.ammonly.com',
104                    'is_superuser' => true,
105                    'is_active'    => true,
106                ])
107                ->withAttribute('scopes', ['*']);
108        }
109
110        if ($this->apiKeyRepository !== null) {
111            $apiKey = $this->apiKeyRepository->findByToken($token);
112            if ($apiKey !== null && $apiKey->isValid()) {
113                $this->apiKeyRepository->touchLastUsed($apiKey->id);
114                return $request
115                    ->withAttribute('auth_type', 'token')
116                    ->withAttribute('user', [
117                        'id'           => 1,
118                        'username'     => $apiKey->name,
119                        'email'        => 'api@ammonly.com',
120                        'is_superuser' => $apiKey->hasScope('*'),
121                        'is_active'    => true,
122                    ])
123                    ->withAttribute('api_key', $apiKey)
124                    ->withAttribute('scopes', $apiKey->scopes);
125            }
126        }
127
128        return null;
129    }
130
131    private const string DEFAULT_SESSION_EMAIL = 'user@ammonly.com';
132
133    /**
134     * Authenticates request via active browser user session (CurrentUser or SessionInterface).
135     *
136     * @param ServerRequestInterface $request Server request.
137     * @return ServerRequestInterface|null Authenticated request or null.
138     */
139    private function authenticateSession(ServerRequestInterface $request): ?ServerRequestInterface
140    {
141        $userId = $this->resolveSessionUserId();
142        if ($userId <= 0) {
143            return null;
144        }
145
146        /** @var array<string, mixed>|null $userPayload */
147        $userPayload = $this->session?->get('user') ?? ($_SESSION['user'] ?? null);
148        $user = (is_array($userPayload) && !empty($userPayload['id'])) ? $userPayload : [
149            'id'           => $userId,
150            'username'     => 'user_' . $userId,
151            'email'        => self::DEFAULT_SESSION_EMAIL,
152            'is_superuser' => (bool) ($userPayload['is_superuser'] ?? false),
153            'is_active'    => true,
154        ];
155
156        return $request
157            ->withAttribute('auth_type', 'session')
158            ->withAttribute('user', $user)
159            ->withAttribute('scopes', ['*']);
160    }
161
162    /**
163     * Resolves authenticated user ID from CurrentUser service, SessionInterface, or global $_SESSION.
164     *
165     * @return int Resolved user ID or 0 if unauthenticated.
166     */
167    private function resolveSessionUserId(): int
168    {
169        if ($this->currentUser !== null && !$this->currentUser->isGuest()) {
170            return (int) $this->currentUser->getId();
171        }
172
173        $sessionUserId = ($this->session !== null && $this->session->has('user_id'))
174            ? (int) $this->session->get('user_id')
175            : (int) ($_SESSION['user_id'] ?? 0);
176
177        return $sessionUserId > 0 ? $sessionUserId : 0;
178    }
179
180    /**
181     * Creates standardized 401 Unauthorized response.
182     *
183     * @return ResponseInterface 401 response.
184     */
185    private function createUnauthorizedResponse(): ResponseInterface
186    {
187        return $this->jsonError(
188            $this->responseFactory,
189            'Unauthorized: Invalid or missing API Bearer token',
190            401
191        );
192    }
193
194    /**
195     * Extracts token strictly from Authorization Bearer header or X-API-Key header.
196     * Tokens in URL query parameters are rejected per OWASP ASVS V3.5.2 to prevent log leakage.
197     *
198     * @param ServerRequestInterface $request PSR-7 Server request.
199     * @return string Token string.
200     */
201    private function extractToken(ServerRequestInterface $request): string
202    {
203        $authHeader = $request->getHeaderLine('Authorization');
204        if (str_starts_with($authHeader, 'Bearer ')) {
205            return trim(substr($authHeader, 7));
206        }
207
208        $apiKeyHeader = $request->getHeaderLine('X-API-Key');
209        if ($apiKeyHeader !== '') {
210            return trim($apiKeyHeader);
211        }
212
213        return '';
214    }
215
216    /**
217     * Obtains valid System API Bearer Token.
218     *
219     * @return string System token.
220     */
221    private function getSystemApiToken(): string
222    {
223        $dbToken = $this->settingsRepository->get('system_api_token', '');
224        if ($dbToken !== '') {
225            return $dbToken;
226        }
227
228        return \App\Shared\Infrastructure\Config\ApiAuthConfigLoader::getSystemApiToken();
229    }
230}