Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
LoginRateLimiterMiddleware
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
7 / 7
20
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%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 handlePostLoginAttempt
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 buildRateLimitResponse
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 getClientIp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadAttempts
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 saveAttempts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Shared\Security;
6
7use App\Shared\Logging\CentralLogger;
8use Psr\Http\Message\ResponseFactoryInterface;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\ServerRequestInterface;
11use Psr\Http\Server\MiddlewareInterface;
12use Psr\Http\Server\RequestHandlerInterface;
13use Yiisoft\Yii\View\Renderer\WebViewRenderer;
14
15 class LoginRateLimiterMiddleware implements MiddlewareInterface
16{
17    private const MAX_ATTEMPTS = 5;
18    private const DECAY_SECONDS = 900; // 15 minut
19
20    public function __construct(
21        private  ResponseFactoryInterface $responseFactory,
22        private  WebViewRenderer $viewRenderer
23    ) {}
24
25    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
26    {
27        if ($request->getMethod() !== 'POST' || $request->getUri()->getPath() !== '/login') {
28            return $handler->handle($request);
29        }
30
31        $ip = $this->getClientIp($request);
32        /** @var array<string, mixed> $body */
33        $body = (array) ($request->getParsedBody() ?? []);
34        $username = is_string($body['username'] ?? null) ? (string) $body['username'] : '';
35
36        $key = 'rate_limit_' . hash('sha256', $ip . '_' . strtolower($username));
37        $cacheFile = sys_get_temp_dir() . '/' . $key . '.json';
38
39        $currentTime = time();
40        $attemptsData = array_filter(
41            $this->loadAttempts($cacheFile),
42            static fn(int $timestamp): bool => ($currentTime - $timestamp) < self::DECAY_SECONDS
43        );
44
45        if (count($attemptsData) >= self::MAX_ATTEMPTS) {
46            $firstAttempt = reset($attemptsData);
47            $retryAfter = self::DECAY_SECONDS - ($currentTime - $firstAttempt);
48            return $this->buildRateLimitResponse($username, count($attemptsData), max(1, $retryAfter));
49        }
50
51        $response = $handler->handle($request);
52        $this->handlePostLoginAttempt($response, $username, $cacheFile, $attemptsData, $currentTime);
53
54        return $response;
55    }
56
57    /**
58     * @param int[] $attemptsData
59     */
60    private function handlePostLoginAttempt(
61        ResponseInterface $response,
62        string $username,
63        string $cacheFile,
64        array $attemptsData,
65        int $currentTime
66    ): void {
67        if ($response->getStatusCode() === 200 && str_contains((string) $response->getBody(), 'Nieprawidłowy login lub hasło')) {
68            CentralLogger::logSecurity('AUTH_FAILURE_INVALID_CREDENTIALS', [
69                'username' => $username,
70            ]);
71            $attemptsData[] = $currentTime;
72            $this->saveAttempts($cacheFile, array_values($attemptsData));
73        } elseif ($response->getStatusCode() === 302 && file_exists($cacheFile)) {
74            @unlink($cacheFile);
75        }
76    }
77
78    private function buildRateLimitResponse(string $username, int $attemptsCount, int $retryAfter): ResponseInterface
79    {
80        CentralLogger::logSecurity('RATE_LIMIT_EXCEEDED', [
81            'username' => $username,
82            'attempts' => $attemptsCount,
83            'retry_after' => $retryAfter,
84        ]);
85
86        $view = $this->viewRenderer->withControllerName('auth');
87        /** @var \Yiisoft\DataResponse\DataResponse $dataResponse */
88        $dataResponse = $view->render('//auth/login.twig', [
89            'error' => sprintf('Zbyt wiele nieudanych prób logowania. Spróbuj ponownie za %d sekund.', $retryAfter),
90        ]);
91
92        $data = $dataResponse->getData();
93        $content = is_string($data) ? $data : '';
94
95        $response = $this->responseFactory->createResponse(429)
96            ->withHeader('Content-Type', 'text/html; charset=UTF-8')
97            ->withHeader('Retry-After', (string) $retryAfter);
98
99        $response->getBody()->write($content);
100        return $response;
101    }
102
103    private function getClientIp(ServerRequestInterface $request): string
104    {
105        $serverParams = $request->getServerParams();
106        $ip = $serverParams['REMOTE_ADDR'] ?? '127.0.0.1';
107        return is_string($ip) ? $ip : '127.0.0.1';
108    }
109
110    /**
111     * @return int[]
112     */
113    private function loadAttempts(string $filepath): array
114    {
115        if (!file_exists($filepath)) {
116            return [];
117        }
118
119        $content = @file_get_contents($filepath);
120        if ($content === false) {
121            return [];
122        }
123
124        /** @var int[]|null $data */
125        $data = json_decode($content, true);
126        return is_array($data) ? $data : [];
127    }
128
129    /**
130     * @param int[] $attempts
131     */
132    private function saveAttempts(string $filepath, array $attempts): void
133    {
134        @file_put_contents($filepath, json_encode(array_values($attempts)));
135    }
136}