Commit b775ab66 authored by Benni Mack's avatar Benni Mack Committed by Christian Kuhn
Browse files

[!!!][TASK] Extract failed login email notifications into separate class

One of TYPO3's major "fat" classes AbstractUserAuthentication
is now thinned out as the "email when X failed login have been
reached within a certain period of time" is moved to
a hook implementation.

AbstractUserAuthentication now does not have
- public property $warningEmail
- public property $warningPeriod
- public property $warningMax
- public method checkLogFailures()
anymore, as this functionality were only
used for this separate logic.

Resolves: #92801
Releases: master
Change-Id: Ib022af408a740bc6c5bbbb219f23e665182ae83c
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/66594


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Oliver Bartsch's avatarOliver Bartsch <bo@cedev.de>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
parent f31c4ee6
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Security;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sends out an email for failed logins in TYPO3 Backend when a certain threshold of failed logins
* during a certain timeframe has happened.
*
* Relevant settings:
* $GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr']
*
* @internal this class is not part of the TYPO3 Core API as this is a concrete hook implementation
*/
class FailedLoginAttemptNotification
{
/**
* The receiver of the notification
* @var string
*/
protected $notificationRecipientEmailAddress;
/**
* Time span (in seconds) within the number of failed logins are collected.
* Number of sections back in time to check. This is a kind of limit for how many failures an hour.
* @var int
*/
protected $warningPeriod;
/**
* The maximum accepted number of warnings before an email to $notificationRecipientEmailAddress is sent
* @var int
*/
protected $failedLoginAttemptsThreshold;
public function __construct(string $notificationRecipientEmailAddress = null, int $warningPeriod = 3600, int $failedLoginAttemptsThreshold = 3)
{
$this->notificationRecipientEmailAddress = $notificationRecipientEmailAddress ?? (string)$GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr'];
$this->warningPeriod = $warningPeriod;
$this->failedLoginAttemptsThreshold = $failedLoginAttemptsThreshold;
}
/**
* Sends a warning email if there has been a certain amount of failed logins during a period.
* If a login fails, this function is called. It will look up the sys_log to see if there
* have been more than $failedLoginAttemptsThreshold failed logins the last X seconds
* (default 3600, see $warningPeriod). If so, an email with a warning is sent.
*
* @param array $params always empty in this hook
* @param AbstractUserAuthentication $user the referenced user where the hook is called.
* @return bool always returns true to ensure "sleep" functionality of AbstractUserAuthentication is kept.
*/
public function sendEmailOnLoginFailures(array $params, AbstractUserAuthentication $user): bool
{
if (!($user instanceof BackendUserAuthentication)) {
// This notification only works for backend users
return true;
}
if (!GeneralUtility::validEmail($this->notificationRecipientEmailAddress)) {
return true;
}
$earliestTimeToCheckForFailures = $GLOBALS['EXEC_TIME'] - $this->warningPeriod;
$loginFailures = $this->getLoginFailures($earliestTimeToCheckForFailures);
// Check for more than a maximum number of login failures with the last period
if (count($loginFailures) > $this->failedLoginAttemptsThreshold) {
// OK, so there were more than the max allowed number of login failures - so we will send an email then.
$this->sendLoginAttemptEmail($loginFailures);
// Login failure attempt written to log, which will be picked up later-on again
$user->writelog(
SystemLogType::LOGIN,
SystemLogLoginAction::SEND_FAILURE_WARNING_EMAIL,
SystemLogErrorClassification::MESSAGE,
3,
'Failure warning (%s failures within %s seconds) sent by email to %s',
[count($loginFailures), $this->warningPeriod, $this->notificationRecipientEmailAddress]
);
}
return true;
}
/**
* Retrieves all failed logins within a given timeframe until now.
*
* @param int $earliestTimeToCheckForFailures A UNIX timestamp that acts as the "earliest" date to check within the logs
* @return array a list of sys_log entries since the earliest, or empty if no entries have been logged
*/
protected function getLoginFailures(int $earliestTimeToCheckForFailures): array
{
// Get last flag set in the log for sending an email
// If a notification was e.g. sent 20mins ago, only check the entries of the last 20 minutes
$queryBuilder = $this->createPreparedQueryBuilder($earliestTimeToCheckForFailures, SystemLogLoginAction::SEND_FAILURE_WARNING_EMAIL);
$statement = $queryBuilder
->select('tstamp')
->orderBy('tstamp', 'DESC')
->setMaxResults(1)
->execute();
if ($lastTimeANotificationWasSent = $statement->fetchColumn()) {
$earliestTimeToCheckForFailures = (int)$lastTimeANotificationWasSent;
}
$queryBuilder = $this->createPreparedQueryBuilder($earliestTimeToCheckForFailures, SystemLogLoginAction::ATTEMPT);
$previousFailures = $queryBuilder
->select('*')
->orderBy('tstamp')
->execute()
->fetchAll();
return is_array($previousFailures) ? $previousFailures : [];
}
/**
* Sends out an email if the number of attempts have exceeded a limit.
*
* @param array $previousFailures sys_log entries that have been logged since the last time a notification was sent
*/
protected function sendLoginAttemptEmail(array $previousFailures): void
{
$emailData = [];
foreach ($previousFailures as $row) {
$theData = unserialize($row['log_data'], ['allowed_classes' => false]);
$text = @sprintf($row['details'], (string)$theData[0], (string)$theData[1], (string)$theData[2]);
if ((int)$row['type'] === SystemLogType::LOGIN) {
$text = str_replace('###IP###', $row['IP'], $text);
}
$emailData[] = [
'row' => $row,
'text' => $text
];
}
$email = GeneralUtility::makeInstance(FluidEmail::class)
->to($this->notificationRecipientEmailAddress)
->setTemplate('Security/LoginAttemptFailedWarning')
->assign('lines', $emailData);
if ($GLOBALS['TYPO3_REQUEST'] instanceof ServerRequestInterface) {
$email->setRequest($GLOBALS['TYPO3_REQUEST']);
}
try {
GeneralUtility::makeInstance(Mailer::class)->send($email);
} catch (TransportExceptionInterface $e) {
// Sending mail failed. Probably broken smtp setup.
// @todo: Maybe log that sending mail failed.
}
}
/**
* @param int $earliestLogDate
* @param int $loginAction
* @return QueryBuilder
*/
protected function createPreparedQueryBuilder(int $earliestLogDate, int $loginAction): QueryBuilder
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_log');
$queryBuilder
->from('sys_log')
->where(
$queryBuilder->expr()->eq(
'type',
$queryBuilder->createNamedParameter(SystemLogType::LOGIN, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq(
'action',
$queryBuilder->createNamedParameter($loginAction, \PDO::PARAM_INT)
),
$queryBuilder->expr()->gt(
'tstamp',
$queryBuilder->createNamedParameter($earliestLogDate, \PDO::PARAM_INT)
)
);
return $queryBuilder;
}
}
......@@ -42,3 +42,4 @@ $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['backend']['previewRendererResolver'] = \
// Register BackendLayoutDataProvider for PageTs
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['BackendLayoutDataProvider']['pagets'] = \TYPO3\CMS\Backend\Provider\PageTsBackendLayoutDataProvider::class;
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['backendUserLogin']['sendEmailOnLogin'] = \TYPO3\CMS\Backend\Security\EmailLoginNotification::class . '->emailAtLogin';
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing']['sendEmailOnFailedLoginAttempt'] = \TYPO3\CMS\Backend\Security\FailedLoginAttemptNotification::class . '->sendEmailOnLoginFailures';
......@@ -195,23 +195,6 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
*/
public $hash_length = 32;
/**
* @var string
*/
public $warningEmail = '';
/**
* Time span (in seconds) within the number of failed logins are collected
* @var int
*/
public $warningPeriod = 3600;
/**
* The maximum accepted number of warnings before an email to $warningEmail is sent
* @var int
*/
public $warningMax = 3;
/**
* If set, the user-record must be stored at the page defined by $checkPid_value
* @var bool
......@@ -752,31 +735,22 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
}
}
// If there were a login failure, check to see if a warning email should be sent:
// If there were a login failure, check to see if a warning email should be sent
if ($this->loginFailure && $activeLogin) {
$this->logger->debug(
'Call checkLogFailures',
[
'warningEmail' => $this->warningEmail,
'warningPeriod' => $this->warningPeriod,
'warningMax' => $this->warningMax
]
);
// Hook to implement login failure tracking methods
$_params = [];
$sleep = true;
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing'] ?? [] as $_funcRef) {
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing'] ?? [] as $hookIdentifier => $_funcRef) {
GeneralUtility::callUserFunction($_funcRef, $_params, $this);
$sleep = false;
// This hack will be removed once this is migrated into PSR-14 Events
if ($hookIdentifier !== 'sendEmailOnFailedLoginAttempt') {
$sleep = false;
}
}
if ($sleep) {
// No hooks were triggered - default login failure behavior is to sleep 5 seconds
sleep(5);
}
$this->checkLogFailures($this->warningEmail, $this->warningPeriod, $this->warningMax);
}
}
......@@ -1338,18 +1312,6 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
{
}
/**
* DUMMY: Check login failures (in some extension classes)
*
* @param string $email Email address
* @param int $secondsBack Number of sections back in time to check. This is a kind of limit for how many failures an hour for instance
* @param int $maxFailures Max allowed failures before a warning mail is sent
* @ignore
*/
public function checkLogFailures($email, $secondsBack, $maxFailures)
{
}
/**
* Raw initialization of the be_user with uid=$uid
* This will circumvent all login procedures and select a be_users record from the
......
......@@ -15,8 +15,6 @@
namespace TYPO3\CMS\Core\Authentication;
use Doctrine\DBAL\Driver\Statement;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Configuration\TypoScript\ConditionMatching\ConditionMatcher;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Cache\CacheManager;
......@@ -30,8 +28,6 @@ use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Core\Resource\Exception;
use TYPO3\CMS\Core\Resource\Filter\FileNameFilter;
use TYPO3\CMS\Core\Resource\Folder;
......@@ -39,7 +35,6 @@ use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\SysLog\Action as SystemLogGenericAction;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Type\Bitmask\BackendGroupMountOption;
......@@ -309,7 +304,6 @@ class BackendUserAuthentication extends AbstractUserAuthentication
public function __construct()
{
$this->name = self::getCookieName();
$this->warningEmail = $GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr'];
$this->sessionTimeout = (int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'];
parent::__construct();
}
......@@ -2299,117 +2293,6 @@ TCAdefaults.sys_note.email = ' . $this->user['email'];
return (int)$connection->lastInsertId('sys_log');
}
/**
* Sends a warning to $email if there has been a certain amount of failed logins during a period.
* If a login fails, this function is called. It will look up the sys_log to see if there
* have been more than $max failed logins the last $secondsBack seconds (default 3600).
* If so, an email with a warning is sent to $email.
*
* @param string $email Email address
* @param int $secondsBack Number of sections back in time to check. This is a kind of limit for how many failures an hour for instance.
* @param int $max Max allowed failures before a warning mail is sent
* @internal
*/
public function checkLogFailures($email, $secondsBack = 3600, $max = 3)
{
if (!GeneralUtility::validEmail($email)) {
return;
}
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
// Get last flag set in the log for sending
$theTimeBack = $GLOBALS['EXEC_TIME'] - $secondsBack;
$queryBuilder = $connectionPool->getQueryBuilderForTable('sys_log');
$queryBuilder->select('tstamp')
->from('sys_log')
->where(
$queryBuilder->expr()->eq(
'type',
$queryBuilder->createNamedParameter(SystemLogType::LOGIN, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq(
'action',
$queryBuilder->createNamedParameter(SystemLogLoginAction::SEND_FAILURE_WARNING_EMAIL, \PDO::PARAM_INT)
),
$queryBuilder->expr()->gt(
'tstamp',
$queryBuilder->createNamedParameter($theTimeBack, \PDO::PARAM_INT)
)
)
->orderBy('tstamp', 'DESC')
->setMaxResults(1);
if ($testRow = $queryBuilder->execute()->fetch(\PDO::FETCH_ASSOC)) {
$theTimeBack = $testRow['tstamp'];
}
$queryBuilder = $connectionPool->getQueryBuilderForTable('sys_log');
$rowCount = $queryBuilder->count('uid')
->from('sys_log')
->where(
$queryBuilder->expr()->eq(
'type',
$queryBuilder->createNamedParameter(SystemLogType::LOGIN, \PDO::PARAM_INT)
),
$queryBuilder->expr()->eq(
'action',
$queryBuilder->createNamedParameter(SystemLogLoginAction::ATTEMPT, \PDO::PARAM_INT)
),
$queryBuilder->expr()->neq(
'error',
$queryBuilder->createNamedParameter(SystemLogErrorClassification::MESSAGE, \PDO::PARAM_INT)
),
$queryBuilder->expr()->gt(
'tstamp',
$queryBuilder->createNamedParameter($theTimeBack, \PDO::PARAM_INT)
)
)
->execute()
->fetchColumn(0);
// Check for more than $max number of error failures with the last period.
if ($rowCount > $max) {
$result = $queryBuilder
->select('*')
->orderBy('tstamp')
->execute();
// OK, so there were more than the max allowed number of login failures - so we will send an email then.
$this->sendLoginAttemptEmail($result, $email);
// Login failure attempt written to log
$this->writelog(SystemLogType::LOGIN, SystemLogLoginAction::SEND_FAILURE_WARNING_EMAIL, SystemLogErrorClassification::MESSAGE, 3, 'Failure warning (%s failures within %s seconds) sent by email to %s', [$rowCount, $secondsBack, $email]);
}
}
/**
* Sends out an email if the number of attempts have exceeded a limit.
*
* @param Statement $result
* @param string $emailAddress
*/
protected function sendLoginAttemptEmail(Statement $result, string $emailAddress): void
{
$emailData = [];
while ($row = $result->fetch(\PDO::FETCH_ASSOC)) {
$theData = unserialize($row['log_data'], ['allowed_classes' => false]);
$text = @sprintf($row['details'], (string)$theData[0], (string)$theData[1], (string)$theData[2]);
if ((int)$row['type'] === SystemLogType::LOGIN) {
$text = str_replace('###IP###', $row['IP'], $text);
}
$emailData[] = [
'row' => $row,
'text' => $text
];
}
$email = GeneralUtility::makeInstance(FluidEmail::class)
->to($emailAddress)
->setTemplate('Security/LoginAttemptFailedWarning')
->assign('lines', $emailData);
if ($GLOBALS['TYPO3_REQUEST'] instanceof ServerRequestInterface) {
$email->setRequest($GLOBALS['TYPO3_REQUEST']);
}
GeneralUtility::makeInstance(Mailer::class)->send($email);
}
/**
* Getter for the cookie name
*
......
.. include:: ../../Includes.txt
=======================================================================================
Breaking: #92801 - Removed "Failed Login" functionality from User Authentication object
=======================================================================================
See :issue:`92801`
Description
===========
The functionality to send an email to a defined sender was previously hard-coded
into the API class "AbstractUserAuthentication" and activated specifically for
Backend Users via the option :php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['warning_email_addr']`.
However, with some custom implementation it was also possible to use a hook to
enable this for frontend users, but the API was not clean.
The backend-user specific logic is now extracted into a hook, so it is possible
to replace this functionality with a custom notification API.
For this reason, the following public properties and methods within
AbstractUserAuthentication and its subclasses have been removed:
* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningEmail`
* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningPeriod`
* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningMax`
* :php:`TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->checkLogFailures()`
Impact
======
Using one of the public properties in custom PHP will trigger a PHP Warning.
Calling the public PHP method will result in a fatal PHP error.
Affected Installations
======================
TYPO3 installations with third-party extensions and custom PHP code that is
related to failed login notifications, and rely on the existing login
notification code.
Migration
=========
As the properties were public, they made it possible to override the
warningMax / warningPeriod values via hooks and middlewares in PHP.
Instead it is recommended to override this functionality via a hook the same way
the new hook in EXT:backend is registered within PHP.
.. index:: Backend, PHP-API, FullyScanned, ext:core
......@@ -4507,4 +4507,18 @@ return [
'Deprecation-92598-Workspace-relatedMethodsFixVersioningPid.rst',
],
],
'TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->checkLogFailures' => [
'numberOfMandatoryArguments' => 3,
'maximumNumberOfArguments' => 3,
'restFiles' => [
'Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst',
],
],
'TYPO3\CMS\Core\Authentication\BackendUserAuthentication->checkLogFailures' => [
'numberOfMandatoryArguments' => 3,
'maximumNumberOfArguments' => 3,
'restFiles' => [
'Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst',
],
],
];
......@@ -705,4 +705,19 @@ return [
'Breaking-92802-DatabaseBasedAuthenticationTimeoutFieldRemoved.rst'
],
],
'TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningEmail' => [
'restFiles' => [
'Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst',
],
],
'TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningPeriod' => [
'restFiles' => [
'Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst',
],
],
'TYPO3\CMS\Core\Authentication\AbstractUserAuthentication->warningMax' => [
'restFiles' => [
'Breaking-92801-RemovedFailedLoginFunctionalityFromUserAuthenticationObject.rst',
],
],
];
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment