Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.76% covered (warning)
87.76%
43 / 49
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
JwtAuthMiddleware
87.76% covered (warning)
87.76%
43 / 49
75.00% covered (warning)
75.00%
3 / 4
11.22
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%
24 / 24
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
90        // Add audit service and request context for explicit logging in actions
91        $request = $request->withAttribute('auditService', $this->auditService);
92        $request = $request->withAttribute('requestId', $this->auditService->getRequestId());
93        $request = $request->withAttribute('clientIp', $ipAddress);
94
95        // Continue to next middleware/action
96        // This is OUTSIDE the try-catch so other exceptions (like PDOException)
97        // bubble up to ErrorLoggingMiddleware properly
98        return $handler->handle($request);
99    }
100
101    /**
102     * Get the client IP address, accounting for proxies.
103     *
104     * @param ServerRequestInterface $request
105     */
106    private function getClientIp(ServerRequestInterface $request): ?string
107    {
108        $serverParams = $request->getServerParams();
109
110        // Check for proxy headers (in order of trust)
111        $headers = [
112            'HTTP_CF_CONNECTING_IP',     // Cloudflare
113            'HTTP_X_REAL_IP',            // Nginx proxy
114            'HTTP_X_FORWARDED_FOR',      // Standard proxy header
115            'REMOTE_ADDR',               // Direct connection
116        ];
117
118        foreach ($headers as $header) {
119            if (!empty($serverParams[$header])) {
120                $ip = $serverParams[$header];
121
122                // X-Forwarded-For can contain multiple IPs, take the first
123                if ($header === 'HTTP_X_FORWARDED_FOR') {
124                    $ips = explode(',', $ip);
125                    $ip = trim($ips[0]);
126                }
127
128                // Validate IP format
129                if (filter_var($ip, FILTER_VALIDATE_IP)) {
130                    return $ip;
131                }
132            }
133        }
134
135        return null;
136    }
137
138    /**
139     * Create an unauthorized response.
140     *
141     * @param string $message
142     *
143     * @return ResponseInterface
144     */
145    private function unauthorizedResponse(string $message): ResponseInterface
146    {
147        $response = new Response();
148        $response->getBody()->write((string)json_encode([
149            'error' => 'Unauthorized',
150            'message' => $message,
151        ]));
152
153        return $response
154            ->withHeader('Content-Type', 'application/json')
155            ->withStatus(401);
156    }
157}