src/EventListener/TwoFactorAuthenticationListener.php line 135

Open in your IDE?
  1. <?php
  2. namespace MedBrief\MSR\EventListener;
  3. use MedBrief\MSR\Service\Security\IPFailedLogin;
  4. use MedBrief\MSR\Service\Security\UserFailedLogin;
  5. use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
  6. use Symfony\Component\HttpFoundation\RedirectResponse;
  7. use Symfony\Component\HttpFoundation\Request;
  8. use Symfony\Component\HttpFoundation\RequestStack;
  9. use Symfony\Component\RateLimiter\LimiterInterface;
  10. use Symfony\Component\RateLimiter\RateLimiterFactory;
  11. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  12. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  13. use Symfony\Component\Security\Core\User\UserInterface;
  14. // This listener listens for two-factor authentication failures and implements rate limiting.
  15. // Users are allowed a maximum of five incorrect 2FA attempts within a five-minute window.
  16. // If the limit is reached, a "rateLimited" flag is stored in the session.
  17. class TwoFactorAuthenticationListener
  18. {
  19. private readonly LimiterInterface $rateLimiter;
  20. private readonly string $clientIpAddress;
  21. public function __construct(
  22. RateLimiterFactory $authenticatedApiLimiter,
  23. private readonly RequestStack $requestStack,
  24. private readonly UserFailedLogin $userFailedLogin,
  25. private readonly IPFailedLogin $IPFailedLogin,
  26. private readonly TokenStorageInterface $tokenStorage,
  27. private readonly UrlGeneratorInterface $urlGenerator,
  28. string $rateLimiterName = 'authenticated_api'
  29. ) {
  30. $this->rateLimiter = $authenticatedApiLimiter->create($rateLimiterName);
  31. $this->clientIpAddress = Request::createFromGlobals()->getClientIp() ?? 'Unknown';
  32. }
  33. /**
  34. * Handles the event when the two-factor authentication form is shown.
  35. *
  36. * This function checks if the current request should be allowed by invoking
  37. * the allowRequest method, which manages rate limiting and login failures.
  38. */
  39. public function onTwoFactorAuthenticationShowForm(): void
  40. {
  41. $this->allowRequest();
  42. }
  43. /**
  44. * Handles the event when the two-factor authentication form is submitted.
  45. *
  46. * This function checks if the current request should be allowed by invoking
  47. * the allowRequest method, which manages rate limiting and login failures. This
  48. * managed /2fa_check
  49. */
  50. public function onTwoFactorAuthenticationAttempt(): void
  51. {
  52. // Additional security added to prevent CSRF attacks.
  53. // If a HTTP GET is attempted instead of a POST, immediately set the rateLimited flag
  54. if ($this->requestStack->getCurrentRequest()->query->has('_auth_code')) {
  55. $session = $this->requestStack->getSession();
  56. // If the session is not started, start it
  57. if (!$session->isStarted()) {
  58. $session->start();
  59. }
  60. // Set the rateLimited flag and log a failure against the username
  61. $session->set('rateLimited', true);
  62. $username = $this->getCurrentUsername();
  63. if ($username !== null) {
  64. $this->userFailedLogin->loginFailure($username, true, true);
  65. $this->IPFailedLogin->loginFailure($username, true, true);
  66. }
  67. }
  68. // If the request is not allowed, redirect to logout
  69. if ($this->allowRequest() === false) {
  70. $response = new RedirectResponse($this->urlGenerator->generate('logout'));
  71. $response->send();
  72. exit();
  73. }
  74. }
  75. /**
  76. * Handles the event when two-factor authentication is successful.
  77. *
  78. * This function will reset the rate limiter and remove the 'rateLimited' flag
  79. * from the session after a successful two-factor authentication.
  80. */
  81. public function onTwoFactorAuthenticationSuccess(): void
  82. {
  83. // Get the current session from the request stack
  84. $session = $this->requestStack->getSession();
  85. // Get the current username from the request stack
  86. $username = $this->getCurrentUsername();
  87. // Reset rate limiter
  88. $session->remove('rateLimited');
  89. // Reset ipBlacklisted and blacklistedIPValue
  90. $session->remove('ipBlacklisted');
  91. $session->remove('blacklistedIPValue');
  92. // Set user login failures to be cleared
  93. $this->userFailedLogin->loginFailureClear($username);
  94. // Set IP address login failures to be cleared
  95. $this->IPFailedLogin->loginFailureClear($username);
  96. // Grab the user and check if they have used their last backup code
  97. $token = $this->tokenStorage->getToken();
  98. $user = $token->getUser();
  99. if ($user instanceof BackupCodeInterface) {
  100. // If no backup codes remain then redirect to re-generation page
  101. if (!$user->hasBackupCodes()) {
  102. $response = new RedirectResponse($this->urlGenerator->generate('mfa_regenerate_backup_codes'));
  103. $response->send();
  104. }
  105. }
  106. }
  107. /**
  108. * Handles the event when two-factor authentication fails.
  109. *
  110. * This function will decrement the rate limiter and set a 'rateLimited' flag
  111. * in the session if the rate limit is exceeded or if the maximum number of
  112. * OTP attempts is reached. If the rate limit is not exceeded, the
  113. * 'rateLimited' flag is removed from the session.
  114. */
  115. public function onTwoFactorAuthenticationFailure(): void
  116. {
  117. $limit = $this->rateLimiter->consume(1);
  118. $session = $this->requestStack->getSession();
  119. if (!$session->isStarted()) {
  120. $session->start();
  121. }
  122. $username = $this->getCurrentUsername();
  123. if ($username !== null) {
  124. $failedOtpCount = $this->userFailedLogin->loginFailure($username, true)['currentOtpCount'];
  125. $failedIPOtpCount = $this->IPFailedLogin->loginFailure($username, true)['currentOtpCount'];
  126. } else {
  127. return;
  128. }
  129. if (!$limit->isAccepted() || $failedOtpCount >= $this->userFailedLogin->getMaxOtpAttempts()) {
  130. // Fails OTP and sets rateLimited flag
  131. $session->set('rateLimited', true);
  132. }
  133. if ($failedIPOtpCount >= $this->IPFailedLogin->getMaxOtpAttempts()) {
  134. // Fails OTP for IP Blacklisting
  135. $session->set('ipBlacklisted', true);
  136. $session->set('blacklistedIPValue', $this->clientIpAddress);
  137. }
  138. }
  139. /**
  140. * Gets the current username if available.
  141. *
  142. * The username is obtained from the currently authenticated user.
  143. * If there is no authenticated user, the method returns null.
  144. *
  145. * @return string|null The current username or null if no user is authenticated.
  146. */
  147. private function getCurrentUsername(): ?string
  148. {
  149. // Get User Token
  150. $token = $this->tokenStorage->getToken();
  151. if (!$token) {
  152. return null;
  153. }
  154. // Get User
  155. $user = $token->getUser();
  156. if (!$user instanceof UserInterface) {
  157. return null;
  158. }
  159. return $user->getUsername() ?? null; // TODO: Replace with `getUserIdentifier()` when upgrading Symfony
  160. }
  161. /**
  162. * Sets the rateLimited session flag if the user has exceeded the maximum otp attempts.
  163. *
  164. * @return bool true if the rateLimited flag was set, false otherwise.
  165. */
  166. private function allowRequest(): bool
  167. {
  168. $session = $this->requestStack->getSession();
  169. if (!$session->isStarted()) {
  170. $session->start();
  171. }
  172. $username = $this->getCurrentUsername();
  173. if ($username !== null && !$this->userFailedLogin->loginAllow($username)) {
  174. $session->set('rateLimited', true);
  175. return false;
  176. }
  177. if (!$this->IPFailedLogin->loginAllow()) {
  178. $session->set('ipBlacklisted', true);
  179. $session->set('blacklistedIPValue', $this->clientIpAddress);
  180. return false;
  181. }
  182. return true;
  183. }
  184. }