SecurityLoginChecker   A
last analyzed

Complexity

Total Complexity 21

Size/Duplication

Total Lines 159
Duplicated Lines 0 %

Test Coverage

Coverage 88.37%

Importance

Changes 0
Metric Value
eloc 42
dl 0
loc 159
ccs 38
cts 43
cp 0.8837
rs 10
c 0
b 0
f 0
wmc 21

4 Methods

Rating   Name   Duplication   Size   Complexity  
A performGlobalLoginCheck() 0 17 3
A performLoginSecurityCheck() 0 26 4
A __construct() 0 7 1
C performLoginCheck() 0 53 13
1
<?php
2
3
namespace App\Module\Security\Login\Service;
4
5
use App\Infrastructure\Settings\Settings;
6
use App\Module\Security\Captcha\Service\SecurityCaptchaVerifier;
7
use App\Module\Security\Enum\SecurityType;
8
use App\Module\Security\Exception\SecurityException;
9
use App\Module\Security\Login\Repository\LoginLogFinderRepository;
10
11
class SecurityLoginChecker
12
{
13
    private array $securitySettings;
14
15 22
    public function __construct(
16
        private readonly SecurityCaptchaVerifier $captchaVerifier,
17
        private readonly LoginRequestFinder $loginRequestFinder,
18
        private readonly LoginLogFinderRepository $loginRequestFinderRepository,
19
        Settings $settings,
20
    ) {
21 22
        $this->securitySettings = $settings->get('security');
22
    }
23
24
    /**
25
     * Threats:
26
     * - Rapid fire attacks (when bots try to log in with 1000 different passwords on one user account)
27
     * - Password spraying (try to log in 1000 different users with most common password).
28
     *
29
     * Perform security check for login requests:
30
     * - coming from the same ip address
31
     * - concerning a specific user account
32
     * - global login requests (throttle after x percent of login failures)
33
     *
34
     * Throttle behaviour: Limit log in attempts per user
35
     * - After x amount of login requests or sent emails in an hour, user has to wait a certain delay before trying again
36
     * - For each next login request or next email sent in the same hour, the user has to wait the same delay
37
     * - Until it eventually increases after value y
38
     * - If login or email requests continue, at amount z captcha is required from the user
39
     * - This rule applies to login requests on a specific user or login requests coming from a specific ip
40
     *
41
     * @param string $email
42
     * @param string|null $reCaptchaResponse
43
     */
44 19
    public function performLoginSecurityCheck(string $email, ?string $reCaptchaResponse = null): void
45
    {
46 19
        if (!$this->securitySettings['throttle_login'] === true) {
47
            return;
48
        }
49
50
        // Standard verification has to be done before captcha check as the captcha may be needed for email
51
        // verification when email is sent to a non-active user for instance
52
        try {
53 19
            $loginEntries = $this->loginRequestFinder->findLoginLogEntriesInTimeLimit($email);
54
            // Perform login check on single user
55 19
            $this->performLoginCheck($loginEntries['logins_by_ip'], $loginEntries['logins_by_email'], $email);
56
            // Global login check
57 7
            $this->performGlobalLoginCheck();
58 14
        } catch (SecurityException $securityException) {
59
            // reCAPTCHA check done AFTER other login checks.
60
            // A captcha can be verified only once, and it may be required later in the login process when security
61
            // check did not fail (e.g. for the email verification to send email to a non-active user).
62 14
            if ($reCaptchaResponse !== null) {
63
                $this->captchaVerifier->verifyReCaptcha(
64
                    $reCaptchaResponse,
65
                    SecurityType::USER_LOGIN
66
                );
67
            } else {
68
                // If security exception was thrown and reCaptcha response is null, throw exception
69 14
                throw $securityException;
70
            }
71
        }
72
    }
73
74
    /**
75
     * Check that login requests in last [timespan] do not exceed the set threshold.
76
     *
77
     * The global threshold is calculated with a ratio from unsuccessful logins to total logins.
78
     * In order for bots not to increase the total login requests and thus manipulating the global threshold,
79
     * the same limit of failed login attempts per user is used also in place for successful logins.
80
     * If the user has 4 unsuccessful login attempts before throttling, he has also 4 successful login requests in
81
     * given timespan before experiencing the same throttling.
82
     *
83
     * @param array{successes: int, failures: int} $loginsByIp login request from ip address
84
     * @param array{successes: int, failures: int} $loginsByEmail login request coming from same email
85
     * @param string $email to get the latest request
86
     */
87 19
    private function performLoginCheck(array $loginsByIp, array $loginsByEmail, string $email): void
88
    {
89
        // Reverse order to compare fails the longest delay first and then go down from there
90 19
        krsort($this->securitySettings['login_throttle_rule']);
91
        // Fails on specific user or coming from specific IP
92 19
        foreach ($this->securitySettings['login_throttle_rule'] as $requestLimit => $delay) {
93
            // Check that there aren't more login successes or failures than tolerated
94
            if (
95 19
                ($loginsByIp['failures'] >= $requestLimit && $loginsByIp['failures'] !== 0)
96 18
                || ($loginsByEmail['failures'] >= $requestLimit && $loginsByEmail['failures'] !== 0)
97
                // To prevent bots from increasing the total login requests and thus manipulating the global threshold,
98
                // the same limit is enforced for failed and successful login attempts
99 17
                || ($loginsByIp['successes'] >= $requestLimit && $loginsByIp['successes'] !== 0)
100 19
                || ($loginsByEmail['successes'] >= $requestLimit && $loginsByEmail['successes'] !== 0)
101
            ) {
102
                // If truthy means: too many ip failures OR too many ip successes
103
                // OR too many failed login tries on specific user OR too many succeeding login requests on specific user
104
105
                // Retrieve the latest email sent for specific email or coming from ip
106 13
                $latestLoginTimestamp = $this->loginRequestFinder->findLatestLoginRequestTimestamp($email);
107
                // created_at in seconds
108 13
                $currentTime = new \DateTime();
109
                // Had issues when deploying the application and testing on github actions. date_default_timezone_set
110
                // isn't taken into account according to https://stackoverflow.com/a/44193886/9013718 because
111
                // time() and date() are timezone independent.
112 13
                $currentTimestamp = (int)$currentTime->setTimezone(new \DateTimeZone('Europe/Zurich'))
113 13
                    ->format('U');
114
115
                // Uncomment to debug
116
                // echo "\n" . 'Current time: ' . $currentTime->format('H:i:s') . "\n" .
117
                //     'Latest login time: ' .
118
                //     (new \DateTime($latestLoginTimestamp))->format('H:i:s') . "\n" .
119
                //     'Delay: ' . $delay . "\n" . (is_numeric($delay) ? 'Time for next login: ' .
120
                //         (new \DateTime())->setTimestamp($delay + $latestLoginTimestamp)
121
                //             ->format('H:i:s') . "\n" . 'Security exception: ' .
122
                //         $securityException = $currentTimestamp < ($timeForNextLogin = $delay + $latestLoginTimestamp) : '') .
123
                //     "\n---- \n";
124
                /** Asserted in @see SecurityLoginCheckerTest (public message is defferent) */
125 13
                $errMsg = 'Exceeded maximum of tolerated login requests';
126 13
                if (is_numeric($delay)) {
127
                    // Check that time is in the future by comparing actual time with forced delay + to latest request
128 9
                    if ($currentTimestamp < ($timeForNextLogin = $delay + $latestLoginTimestamp)) {
129 9
                        $remainingDelay = (int)($timeForNextLogin - $currentTimestamp);
130 9
                        throw new SecurityException($remainingDelay, SecurityType::USER_LOGIN, $errMsg);
131
                    }
132 5
                } elseif ($delay === 'captcha') {
133 5
                    $errMsg .= ' with captcha';
134 5
                    throw new SecurityException($delay, SecurityType::USER_LOGIN, $errMsg);
135
                }
136
            }
137
        }
138
        // Revert krsort() done earlier to prevent unexpected behaviour later when working with ['login_throttle_rule']
139 7
        ksort($this->securitySettings['login_throttle_rule']);
140
    }
141
142
    /**
143
     * Perform global login check - allow up to x percent of login failures.
144
     *
145
     * For the global request check set the login threshold to some ratio from unsuccessful to the total logins.
146
     * (permitting like 20% of total login requests to be unsuccessful).
147
     *
148
     * In order for bots not to increase the total login requests and thus manipulating the global threshold,
149
     * the same limit on failed login attempts per user is used also for successful logins.
150
     * If the user has 4 unsuccessful login attempts before throttling, he has also 4 successful login attempts
151
     * before experiencing the same throttling.
152
     */
153 7
    private function performGlobalLoginCheck(): void
154
    {
155
        // Making sure that values returned from the repository are cast into integers
156 7
        $loginAmountSummary = array_map('intval', $this->loginRequestFinderRepository->getGlobalLoginAmountSummary());
157
158
        // Calc allowed failure amount which is the given login_failure_percentage of the total login
159 7
        $failureThreshold = floor(
160 7
            $loginAmountSummary['total_amount'] / 100 * $this->securitySettings['login_failure_percentage']
161 7
        );
162
        // Actual failure amount have to be LESS than allowed failures amount (tested this way).
163
        // If there are not enough requests to be representative, the failureThreshold is increased to 20 meaning
164
        // at least 20 failed login attempts are allowed no matter the percentage.
165
        // If percentage is 10, throttle begins at 200 login requests.
166 7
        if (!($loginAmountSummary['failures'] < $failureThreshold) && $failureThreshold > 20) {
167
            // If changed, update SecurityServiceTest password spraying test expected error message
168 1
            $msg = 'Maximum amount of tolerated unrestricted login requests reached site-wide.';
169 1
            throw new SecurityException('captcha', SecurityType::GLOBAL_LOGIN, $msg);
170
        }
171
    }
172
}
173