SecurityEmailChecker   A
last analyzed

Complexity

Total Complexity 18

Size/Duplication

Total Lines 120
Duplicated Lines 0 %

Test Coverage

Coverage 89.74%

Importance

Changes 0
Metric Value
eloc 36
dl 0
loc 120
ccs 35
cts 39
cp 0.8974
rs 10
c 0
b 0
f 0
wmc 18

4 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A performEmailAbuseCheck() 0 18 5
A performGlobalEmailCheck() 0 21 5
B performEmailRequestsCheck() 0 28 7
1
<?php
2
3
namespace App\Module\Security\Email\Service;
4
5
use App\Infrastructure\Settings\Settings;
6
use App\Module\Security\Captcha\Service\SecurityCaptchaVerifier;
7
use App\Module\Security\Email\Repository\EmailLogFinderRepository;
8
use App\Module\Security\Enum\SecurityType;
9
use App\Module\Security\Exception\SecurityException;
10
11
class SecurityEmailChecker
12
{
13
    private array $securitySettings;
14
15 29
    public function __construct(
16
        private readonly SecurityCaptchaVerifier $captchaVerifier,
17
        private readonly EmailRequestFinder $emailRequestFinder,
18
        private readonly EmailLogFinderRepository $requestFinderRepository,
19
        Settings $settings,
20
    ) {
21 29
        $this->securitySettings = $settings->get('security');
22
    }
23
24
    /**
25
     * Threat: Email abuse (sending a lot of emails may be costly).
26
     *
27
     * Throttle behaviour: Limit email sending
28
     * - After x amount of emails sent from ip or user they have 3 thresholds with
29
     *    different waiting times
30
     * - After the last threshold is reached, captcha is required for every email sent
31
     * - Limit applies to last [timespan]. If waited enough, users can send unrestricted emails again
32
     * - Globally there are two optional rules:
33
     *   1. Defined daily limit - after it is reached, captcha is required for every user
34
     *   2. Monthly limit - after it is reached, captcha is required for every user (mailgun resets after 1st)
35
     *
36
     * Perform email abuse check
37
     * - coming from the same ip address
38
     * - concerning a specific email address
39
     * - global email requests
40
     *
41
     * @param string|null $email
42
     * @param string|null $reCaptchaResponse
43
     * @param int|null $userId
44
     *
45
     * @throws SecurityException
46
     */
47 15
    public function performEmailAbuseCheck(?string $email, ?string $reCaptchaResponse = null, ?int $userId = null): void
48
    {
49 15
        if ($this->securitySettings['throttle_email'] === true && isset($email)) {
50 15
            $validCaptcha = false;
51
            // reCAPTCHA verification
52 15
            if ($reCaptchaResponse !== null) {
53
                $validCaptcha = $this->captchaVerifier->verifyReCaptcha(
54
                    $reCaptchaResponse,
55
                    SecurityType::USER_EMAIL
56
                );
57
            }
58
            // If captcha is valid, the other security checks don't have to be made
59 15
            if ($validCaptcha !== true) {
60 15
                $emailsAmount = $this->emailRequestFinder->findEmailAmountInSetTimespan($email, $userId);
61
                // Email checks (register, password recovery, other with email)
62 15
                $this->performEmailRequestsCheck($emailsAmount, $email);
63
                // Global email check
64 9
                $this->performGlobalEmailCheck();
65
            }
66
        }
67
    }
68
69
    /**
70
     * Make email abuse check for requests coming from same ip
71
     * or concerning the same email address.
72
     *
73
     * @param int $emailsAmount amount of emails sent in the last timespan
74
     * @param string $email
75
     */
76 15
    private function performEmailRequestsCheck(
77
        int $emailsAmount,
78
        string $email,
79
    ): void {
80 15
        if (isset($this->securitySettings['user_email_throttle_rule'])) {
81
            // Reverse order to compare fails the longest delay first and then go down from there
82 15
            krsort($this->securitySettings['user_email_throttle_rule']);
83
            // Fails on specific user or coming from specific IP
84 15
            foreach ($this->securitySettings['user_email_throttle_rule'] as $requestLimit => $delay) {
85
                // If sent emails in the last given timespan is greater than the tolerated amount of requests with email per timespan
86 15
                if ($emailsAmount >= $requestLimit) {
87
                    // Retrieve the latest email sent created_at in seconds
88 6
                    $latestEmailTimestamp = $this->emailRequestFinder->findLastEmailRequestTimestamp($email);
89
90 6
                    $errMsg = 'Exceeded maximum of tolerated emails.'; // Change in SecurityServiceTest as well
91 6
                    if (is_numeric($delay)) {
92
                        // Check that time is in the future by comparing actual time with forced delay + to the latest request
93 4
                        if (time() < ($timeForNextRequest = $delay + $latestEmailTimestamp)) {
94 4
                            $remainingDelay = (int)($timeForNextRequest - time());
95 4
                            throw new SecurityException($remainingDelay, SecurityType::USER_EMAIL, $errMsg);
96
                        }
97 2
                    } elseif ($delay === 'captcha') {
98 2
                        throw new SecurityException($delay, SecurityType::USER_EMAIL, $errMsg);
99
                    }
100
                }
101
            }
102
            // Revert krsort() done earlier to prevent unexpected behaviour later when working with ['user_email_throttle_rule']
103 9
            ksort($this->securitySettings['user_email_throttle_rule']);
104
        }
105
    }
106
107
    /**
108
     * Protection against email abuse.
109
     */
110 9
    private function performGlobalEmailCheck(): void
111
    {
112
        // Order of calls on getGlobalSentEmailAmount() matters in test. First daily and then monthly should be called
113
114
        // Check emails for daily threshold
115 9
        if (isset($this->securitySettings['global_daily_email_threshold'])) {
116 9
            $sentEmailAmountInLastDay = $this->requestFinderRepository->getGlobalSentEmailAmount(1);
117
            // If sent emails exceed or equal the given threshold
118 9
            if ($sentEmailAmountInLastDay >= $this->securitySettings['global_daily_email_threshold']) {
119 1
                $msg = 'Maximum amount of unrestricted email sending daily reached site-wide.';
120 1
                throw new SecurityException('captcha', SecurityType::GLOBAL_EMAIL, $msg);
121
            }
122
        }
123
124
        // Check emails for monthly threshold
125 8
        if (isset($this->securitySettings['global_monthly_email_threshold'])) {
126 8
            $sentEmailAmountInLastMonth = $this->requestFinderRepository->getGlobalSentEmailAmount(30);
127
            // If sent emails exceed or equal the given threshold
128 8
            if ($sentEmailAmountInLastMonth >= $this->securitySettings['global_monthly_email_threshold']) {
129 1
                $msg = 'Maximum amount of unrestricted email sending monthly reached site-wide.';
130 1
                throw new SecurityException('captcha', SecurityType::GLOBAL_EMAIL, $msg);
131
            }
132
        }
133
    }
134
}
135