Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.20% covered (success)
90.20%
46 / 51
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
JwtAuthMiddleware
90.20% covered (success)
90.20%
46 / 51
75.00% covered (warning)
75.00%
3 / 4
12.14
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
 process
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 getClientIp
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
6.92
 unauthorizedResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Middleware;
6
7use App\Domain\Audit\AuditService;
8use App\Domain\Auth\Service\AuthService;
9use App\Support\Row;
10use Nyholm\Psr7\Response;
11use Psr\Http\Message\ResponseInterface;
12use Psr\Http\Message\ServerRequestInterface;
13use Psr\Http\Server\MiddlewareInterface;
14use Psr\Http\Server\RequestHandlerInterface;
15use RuntimeException;
16
17/**
18 * Middleware class to handle JWT-based authentication and user context management.
19 *
20 * This middleware intercepts incoming requests, extracts an access token from
21 * the Authorization header, authenticates the token, and establishes user-related
22 * context for later request handling.
23 *
24 * Responsibilities include:
25 * - Verifying the presence and format of the Authorization header.
26 * - Decoding and validating the JWT to get the current user.
27 * - Setting audit information (user ID, username, IP address) for database logging.
28 * - Injecting user and other context attributes (user role, request ID, client IP, etc.)
29 *   into the request for use in later request handling or actions.
30 * - Handling unauthorized responses in cases of invalid or missing tokens.
31 *
32 * This middleware supports proxies by extracting the client IP address from trusted
33 * headers (e.g., HTTP_X_FORWARDED_FOR, HTTP_CF_CONNECTING_IP).
34 */
35final class JwtAuthMiddleware implements MiddlewareInterface
36{
37    public function __construct(
38        private readonly AuthService $authService,
39        private readonly AuditService $auditService,
40    ) {}
41
42    /**
43     * @param ServerRequestInterface $request
44     * @param RequestHandlerInterface $handler
45     *
46     * @return ResponseInterface
47     */
48    public function process(
49        ServerRequestInterface $request,
50        RequestHandlerInterface $handler,
51    ): ResponseInterface {
52        // Extract token from the Authorization header
53        $authHeader = $request->getHeaderLine('Authorization');
54
55        if (empty($authHeader)) {
56            return $this->unauthorizedResponse('Missing authorization header');
57        }
58
59        // Check if it starts with "Bearer"
60        if (!preg_match('/^Bearer\s+(.*)$/i', $authHeader, $matches)) {
61            return $this->unauthorizedResponse('Invalid authorization header format');
62        }
63
64        $token = $matches[1];
65
66        // Authenticate the token - ONLY this part should return 401 on failure
67        try {
68            $user = $this->authService->getCurrentUser($token);
69        } catch (RuntimeException $e) {
70            // Only authentication failures should return 401
71            return $this->unauthorizedResponse($e->getMessage());
72        }
73
74        // Get client IP address
75        $ipAddress = $this->getClientIp($request);
76
77        // Set audit context for database triggers
78        $this->auditService->setContext(
79            userId: $user->userId,
80            changedBy: $user->username,
81            ipAddress: $ipAddress,
82        );
83
84        // Add user info to request attributes for use in actions
85        $request = $request->withAttribute('user', $user);
86        $request = $request->withAttribute('userId', $user->userId);
87        $request = $request->withAttribute('username', $user->username);
88        $request = $request->withAttribute('investorId', $user->investorId);
89        $request = $request->withAttribute('userRole', $user->role);
90        $request = $request->withAttribute('isAdmin', in_array($user->role, ['admin', 'super_admin'], true));
91
92        // Add audit service and request context for explicit logging in actions
93        $request = $request->withAttribute('auditService', $this->auditService);
94        $request = $request->withAttribute('requestId', $this->auditService->getRequestId());
95        $request = $request->withAttribute('clientIp', $ipAddress);
96
97        // Continue to next middleware/action
98        // This is OUTSIDE the try-catch so other exceptions (like PDOException)
99        // bubble up to ErrorLoggingMiddleware properly
100        return $handler->handle($request);
101    }
102
103    /**
104     * Get the client IP address, accounting for proxies.
105     *
106     * @param ServerRequestInterface $request
107     */
108    private function getClientIp(ServerRequestInterface $request): ?string
109    {
110        $serverParams = $request->getServerParams();
111
112        // Check for proxy headers (in order of trust)
113        $headers = [
114            'HTTP_CF_CONNECTING_IP',     // Cloudflare
115            'HTTP_X_REAL_IP',            // Nginx proxy
116            'HTTP_X_FORWARDED_FOR',      // Standard proxy header
117            'REMOTE_ADDR',               // Direct connection
118        ];
119
120        foreach ($headers as $header) {
121            $ip = Row::nullableString($serverParams, $header);
122            if ($ip === null || $ip === '') {
123                continue;
124            }
125
126            // X-Forwarded-For can contain multiple IPs, take the first
127            if ($header === 'HTTP_X_FORWARDED_FOR') {
128                $ips = explode(',', $ip);
129                $ip = trim($ips[0]);
130            }
131
132            // Validate IP format
133            if (filter_var($ip, FILTER_VALIDATE_IP)) {
134                return $ip;
135            }
136        }
137
138        return null;
139    }
140
141    /**
142     * Create an unauthorized response.
143     *
144     * @param string $message
145     *
146     * @return ResponseInterface
147     */
148    private function unauthorizedResponse(string $message): ResponseInterface
149    {
150        $response = new Response();
151        $response->getBody()->write((string)json_encode([
152            'error' => 'Unauthorized',
153            'message' => $message,
154        ]));
155
156        return $response
157            ->withHeader('Content-Type', 'application/json')
158            ->withStatus(401);
159    }
160}