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