<?php
namespace MedBrief\MSR\EventListener;
use MedBrief\MSR\Service\Security\IPFailedLogin;
use MedBrief\MSR\Service\Security\UserFailedLogin;
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
// This listener listens for two-factor authentication failures and implements rate limiting.
// Users are allowed a maximum of five incorrect 2FA attempts within a five-minute window.
// If the limit is reached, a "rateLimited" flag is stored in the session.
class TwoFactorAuthenticationListener
{
private readonly LimiterInterface $rateLimiter;
private readonly string $clientIpAddress;
public function __construct(
RateLimiterFactory $authenticatedApiLimiter,
private readonly RequestStack $requestStack,
private readonly UserFailedLogin $userFailedLogin,
private readonly IPFailedLogin $IPFailedLogin,
private readonly TokenStorageInterface $tokenStorage,
private readonly UrlGeneratorInterface $urlGenerator,
string $rateLimiterName = 'authenticated_api'
) {
$this->rateLimiter = $authenticatedApiLimiter->create($rateLimiterName);
$this->clientIpAddress = Request::createFromGlobals()->getClientIp() ?? 'Unknown';
}
/**
* Handles the event when the two-factor authentication form is shown.
*
* This function checks if the current request should be allowed by invoking
* the allowRequest method, which manages rate limiting and login failures.
*/
public function onTwoFactorAuthenticationShowForm(): void
{
$this->allowRequest();
}
/**
* Handles the event when the two-factor authentication form is submitted.
*
* This function checks if the current request should be allowed by invoking
* the allowRequest method, which manages rate limiting and login failures. This
* managed /2fa_check
*/
public function onTwoFactorAuthenticationAttempt(): void
{
// Additional security added to prevent CSRF attacks.
// If a HTTP GET is attempted instead of a POST, immediately set the rateLimited flag
if ($this->requestStack->getCurrentRequest()->query->has('_auth_code')) {
$session = $this->requestStack->getSession();
// If the session is not started, start it
if (!$session->isStarted()) {
$session->start();
}
// Set the rateLimited flag and log a failure against the username
$session->set('rateLimited', true);
$username = $this->getCurrentUsername();
if ($username !== null) {
$this->userFailedLogin->loginFailure($username, true, true);
$this->IPFailedLogin->loginFailure($username, true, true);
}
}
// If the request is not allowed, redirect to logout
if ($this->allowRequest() === false) {
$response = new RedirectResponse($this->urlGenerator->generate('logout'));
$response->send();
exit();
}
}
/**
* Handles the event when two-factor authentication is successful.
*
* This function will reset the rate limiter and remove the 'rateLimited' flag
* from the session after a successful two-factor authentication.
*/
public function onTwoFactorAuthenticationSuccess(): void
{
// Get the current session from the request stack
$session = $this->requestStack->getSession();
// Get the current username from the request stack
$username = $this->getCurrentUsername();
// Reset rate limiter
$session->remove('rateLimited');
// Reset ipBlacklisted and blacklistedIPValue
$session->remove('ipBlacklisted');
$session->remove('blacklistedIPValue');
// Set user login failures to be cleared
$this->userFailedLogin->loginFailureClear($username);
// Set IP address login failures to be cleared
$this->IPFailedLogin->loginFailureClear($username);
// Grab the user and check if they have used their last backup code
$token = $this->tokenStorage->getToken();
$user = $token->getUser();
if ($user instanceof BackupCodeInterface) {
// If no backup codes remain then redirect to re-generation page
if (!$user->hasBackupCodes()) {
$response = new RedirectResponse($this->urlGenerator->generate('mfa_regenerate_backup_codes'));
$response->send();
}
}
}
/**
* Handles the event when two-factor authentication fails.
*
* This function will decrement the rate limiter and set a 'rateLimited' flag
* in the session if the rate limit is exceeded or if the maximum number of
* OTP attempts is reached. If the rate limit is not exceeded, the
* 'rateLimited' flag is removed from the session.
*/
public function onTwoFactorAuthenticationFailure(): void
{
$limit = $this->rateLimiter->consume(1);
$session = $this->requestStack->getSession();
if (!$session->isStarted()) {
$session->start();
}
$username = $this->getCurrentUsername();
if ($username !== null) {
$failedOtpCount = $this->userFailedLogin->loginFailure($username, true)['currentOtpCount'];
$failedIPOtpCount = $this->IPFailedLogin->loginFailure($username, true)['currentOtpCount'];
} else {
return;
}
if (!$limit->isAccepted() || $failedOtpCount >= $this->userFailedLogin->getMaxOtpAttempts()) {
// Fails OTP and sets rateLimited flag
$session->set('rateLimited', true);
}
if ($failedIPOtpCount >= $this->IPFailedLogin->getMaxOtpAttempts()) {
// Fails OTP for IP Blacklisting
$session->set('ipBlacklisted', true);
$session->set('blacklistedIPValue', $this->clientIpAddress);
}
}
/**
* Gets the current username if available.
*
* The username is obtained from the currently authenticated user.
* If there is no authenticated user, the method returns null.
*
* @return string|null The current username or null if no user is authenticated.
*/
private function getCurrentUsername(): ?string
{
// Get User Token
$token = $this->tokenStorage->getToken();
if (!$token) {
return null;
}
// Get User
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return null;
}
return $user->getUsername() ?? null; // TODO: Replace with `getUserIdentifier()` when upgrading Symfony
}
/**
* Sets the rateLimited session flag if the user has exceeded the maximum otp attempts.
*
* @return bool true if the rateLimited flag was set, false otherwise.
*/
private function allowRequest(): bool
{
$session = $this->requestStack->getSession();
if (!$session->isStarted()) {
$session->start();
}
$username = $this->getCurrentUsername();
if ($username !== null && !$this->userFailedLogin->loginAllow($username)) {
$session->set('rateLimited', true);
return false;
}
if (!$this->IPFailedLogin->loginAllow()) {
$session->set('ipBlacklisted', true);
$session->set('blacklistedIPValue', $this->clientIpAddress);
return false;
}
return true;
}
}