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
|
|
|
|