Disable invalid_attempts config + Refactor for Contao 4.9

This commit is contained in:
Richard Henkenjohann committed 2020-10-09 15:05:47 +02:00
1 parent 7d17d3a9c6
commit 8d6345ac6e
14 files changed
+104 -277

No files matched your search

-14
View File
@@ -52,7 +52,6 @@ terminal42_password_validation:
numbers: 1
other: 1
other_chars: "+*ç%&/()=?"
invalid_attempts: 3
password_history: 10
change_days: 90
Contao\BackendUser:
@@ -61,7 +60,6 @@ terminal42_password_validation:
Parameter | Purpose
--------- | -------
`invalid_attempts`: | Disable the user. Requires an admin to enable the account. Create a notification with type "account_disabled" which will be sent out to the admin and/or user.
`password_history`: | Keep track of the latest `n` passwords, and force the users not to choose one of their recent passwords.
`change_days`: | Ask the user to change their password after certain days.
@@ -71,18 +69,6 @@ Parameter | Purpose
the page root.
2. You can now force members to change their passwords by ticking the corresponding checkbox in the member edit-mask.
### Notifications
#### Account disabled
The bundle triggers a notification whenever an account gets disabled (only when using `invalid_attempts` options). This allows you to inform both website admin and account holder.
To create such notification, you can create a notification of type "Account disabled" in the notification center. This notification is chosen automatically for both back and front end users. If you only have one notification of this type, no further action is needed.
However, you have the possibility to select a dedicated notification:
- To use a dedicated notification for users and members, set the `nc_account_disabled` parameter in the above mentioned config tree. Just set the notification ID as parameter value.
- To use a dedicated notification for members on a certain page, you can select a notification in the page root config.
## Add your own password validator
You can add your own validation rule, e.g. a dictionary check.
+4 -5
View File
@@ -26,12 +26,11 @@
},
"require": {
"php": "^7.1",
"contao/core-bundle": "^4.4",
"contao/core-bundle": "^4.9",
"paragonie/hidden-string": "^1.0",
"symfony/config": "^3.3 || ^4.0",
"symfony/dependency-injection": "^3.3 || ^4.0",
"symfony/http-kernel": "^3.3 || ^4.0",
"terminal42/notification_center": "^1.3"
"symfony/config": "^4.0",
"symfony/dependency-injection": "^4.0",
"symfony/http-kernel": "^4.0"
},
"require-dev": {
"contao/manager-plugin": "^2.7",
+6 -2
View File
@@ -29,8 +29,12 @@ final class Configuration implements ConfigurationInterface
->children()
->integerNode('min_length')->end()
->integerNode('max_length')->end()
->integerNode('invalid_attempts')->end()
->integerNode('nc_account_disabled')->end()
->integerNode('invalid_attempts')
->setDeprecated('The "%node%" option is not available since Contao 4.9 comes with improved brute-force-protection.')
->end()
->integerNode('nc_account_disabled')
->setDeprecated('The "%node%" option is not available since Contao 4.9 comes with improved brute-force-protection.')
->end()
->integerNode('password_history')->end()
->integerNode('change_days')->end()
->scalarNode('other_chars')->end()
@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
/*
* Password Validation Bundle for Contao Open Source CMS.
*
* @copyright Copyright (c) 2020, terminal42 gmbh
* @author terminal42 <https://terminal42.ch>
* @license MIT
* @link http://github.com/terminal42/contao-password-validation
*/
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\BackendUser;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\FrontendUser;
use Contao\Message;
use Contao\PageModel;
use Contao\User;
use NotificationCenter\Model\Notification;
use Terminal42\PasswordValidationBundle\Validation\ValidationConfiguration;
final class InvalidAttemptsListener
{
private $configuration;
private $framework;
public function __construct(ValidationConfiguration $configuration, ContaoFramework $framework)
{
$this->configuration = $configuration;
$this->framework = $framework;
}
/**
* Disable account if maximum invalid attempts reached.
*
* @return bool Never return true as it would authenticate the user!
*/
public function onCheckCredentials(string $username, string $password, User $user): bool
{
if (null === $this->getMaximumInvalidAttempts(\get_class($user))) {
return false;
}
if ($user instanceof BackendUser && $user->isAdmin) {
return false;
}
// Check for <=1 because the loginCount will be decreased after this hook call.
if ($user->loginCount <= 1) {
// Disable the account
$user->disable = '1';
// Reset the login count as we do not want the default routine to lock the account.
// No need to save as the user will be saved nonetheless.
++$user->loginCount;
Message::addError($this->translate('accountDisabled'));
if (null !== $notification = $this->getNotification($user)) {
$notification->send($this->getNotificationTokens($username, $user));
}
}
return false;
}
/**
* Set loginCount to maximum allowed attempts after successful login.
*/
public function onPostLogin(User $user): void
{
if (null === $maximumAttempts = $this->getMaximumInvalidAttempts(\get_class($user))) {
return;
}
$user->loginCount = $maximumAttempts;
$user->save();
}
private function getMaximumInvalidAttempts(string $userEntity): ?int
{
if (false === $this->configuration->hasConfiguration($userEntity)) {
return null;
}
$configuration = $this->configuration->getConfiguration($userEntity);
if ($invalidAttempts = $configuration['invalid_attempts']) {
return $invalidAttempts;
}
return null;
}
private function getNotification(User $user): ?Notification
{
/** @var PageModel $adapter */
$adapter = $this->framework->getAdapter(PageModel::class);
if (($user instanceof FrontendUser) && null !== $rootPage = $adapter->findByPk($GLOBALS['objPage']->rootId)) {
$notification = Notification::findByPk($rootPage->nc_account_disabled);
}
$configuration = $this->configuration->getConfiguration(\get_class($user));
if (null === $notification && $notificationId = $configuration['nc_account_disabled']) {
$notification = Notification::findByPk($notificationId);
}
if (null === $notification) {
$notification = Notification::findOneBy('type', 'account_disabled');
}
return $notification;
}
private function getNotificationTokens(string $username, User $user): array
{
$tokens = [];
$tokens['user_class'] = \get_class($user);
$tokens['username'] = $username;
foreach ($user->getData() as $k => $v) {
$tokens['user_'.$k] = $v;
}
$tokens['admin_email'] = $GLOBALS['TL_ADMIN_EMAIL'];
return $tokens;
}
private function translate(string $key)
{
return $GLOBALS['TL_LANG']['ERR']['passwordValidation'][$key];
}
}
@@ -13,6 +13,8 @@ declare(strict_types=1);
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Callback;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\FrontendUser;
use Contao\Input;
use Contao\MemberModel;
@@ -33,6 +35,9 @@ final class NoPwChangePageWarning
$this->configuration = $configuration;
}
/**
* @Hook("getSystemMessages")
*/
public function onGetSystemMessages(): string
{
$needsPwChangePage = false;
@@ -68,6 +73,9 @@ final class NoPwChangePageWarning
return '';
}
/**
* @Callback(table="tl_page", target="config.onload")
*/
public function tlPageShowWarning(): void
{
if (Input::get('act')) {
+4 -1
View File
@@ -13,12 +13,15 @@ declare(strict_types=1);
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\CoreBundle\ServiceAnnotation\Callback;
use Contao\User;
use Terminal42\PasswordValidationBundle\Model\PasswordHistory;
use Terminal42\PasswordValidationBundle\Validation\ValidationConfiguration;
/**
* This listener forces a password change for passwords being too old.
*
* @Callback("postLogin")
*/
final class PasswordChangeListener
{
@@ -29,7 +32,7 @@ final class PasswordChangeListener
$this->configuration = $configuration;
}
public function onPostLogin(User $user): void
public function __invoke(User $user): void
{
if (null === $maxDays = $this->getMaxDays($user)) {
return;
@@ -14,6 +14,8 @@ declare(strict_types=1);
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\BackendUser;
use Contao\CoreBundle\ServiceAnnotation\Callback;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\Database\Result as DatabaseResult;
use Contao\DataContainer;
use Contao\FrontendUser;
@@ -49,6 +51,8 @@ final class PasswordHistoryListener
/**
* This hook is triggered for frontend users exclusively (ModuleChangePassword and save_callback).
*
* @Hook("setNewPassword")
*
* @param MemberModel|DatabaseResult $member
*/
public function onSetNewPassword($member, string $password): void
@@ -94,6 +98,8 @@ final class PasswordHistoryListener
/**
* This listener keeps track of the backend user's passwords.
*
* @Callback(table="tl_user", target="fields.password.save")
*/
public function onBackendSaveCallback(string $password, DataContainer $dc): string
{
@@ -126,10 +132,6 @@ final class PasswordHistoryListener
private function hashPassword(string $password): string
{
if (version_compare(VERSION, '4.8', '>=')) {
return $this->encoderFactory->getEncoder(User::class)->encodePassword($password, null);
}
return password_hash($password, PASSWORD_DEFAULT);
return $this->encoderFactory->getEncoder(User::class)->encodePassword($password, null);
}
}
+4 -1
View File
@@ -14,6 +14,7 @@ declare(strict_types=1);
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\BackendUser;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\FrontendUser;
use Contao\Widget;
use ParagonIE\HiddenString\HiddenString;
@@ -24,6 +25,8 @@ use Terminal42\PasswordValidationBundle\Validation\ValidatorManager;
/**
* This listener validates the password input by providing a regexp.
*
* @Hook("addCustomRegexp")
*/
final class PasswordRegexpListener
{
@@ -37,7 +40,7 @@ final class PasswordRegexpListener
$this->configuration = $configuration;
}
public function onAddCustomRegexp(string $rgxp, $input, Widget $widget): bool
public function __invoke(string $rgxp, $input, Widget $widget): bool
{
if ('terminal42_password_validation' !== $rgxp) {
return false;
@@ -16,13 +16,14 @@ namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\CoreBundle\Exception\RedirectResponseException;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Routing\ScopeMatcher;
use Contao\FrontendUser;
use Contao\MemberModel;
use Contao\PageModel;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final class PasswordChangeFrontendListener
final class RedirectToPasswordChangePageListener
{
private $framework;
@@ -30,24 +31,38 @@ final class PasswordChangeFrontendListener
private $authenticationTrustResolver;
private $scopeMatcher;
public function __construct(
ContaoFramework $framework,
TokenStorageInterface $tokenStorage,
AuthenticationTrustResolverInterface $authenticationTrustResolver
AuthenticationTrustResolverInterface $authenticationTrustResolver,
ScopeMatcher $scopeMatcher
) {
$this->framework = $framework;
$this->tokenStorage = $tokenStorage;
$this->authenticationTrustResolver = $authenticationTrustResolver;
$this->scopeMatcher = $scopeMatcher;
}
public function onGetPageLayout(PageModel $page): void
public function __invoke(RequestEvent $event): void
{
if (!$this->scopeMatcher->isFrontendMasterRequest($event)) {
return;
}
$token = $this->tokenStorage->getToken();
if (null === $token || $this->authenticationTrustResolver->isAnonymous($token)) {
return;
}
$this->framework->initialize();
$request = $event->getRequest();
$page = $request->attributes->get('pageModel');
// Check if actual page is available
if (!$page instanceof PageModel) {
return;
}
$user = $token->getUser();
if (!$user instanceof FrontendUser) {
@@ -77,14 +92,4 @@ final class PasswordChangeFrontendListener
// Redirect to password-change page
throw new RedirectResponseException($pwChangePage->getAbsoluteUrl());
}
public function onSetNewPassword($member): void
{
if ($member instanceof MemberModel) {
// We only want to reset "pwChange" if the user changed their password with the change-password module.
// If this hook is called within the save_callback (backend mask), $member is an instance of Database\Result.
$member->pwChange = '';
$member->save();
}
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* Password Validation Bundle for Contao Open Source CMS.
*
* @copyright Copyright (c) 2020, terminal42 gmbh
* @author terminal42 <https://terminal42.ch>
* @license MIT
* @link http://github.com/terminal42/contao-password-validation
*/
namespace Terminal42\PasswordValidationBundle\EventListener;
use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\CoreBundle\ServiceAnnotation\Hook;
use Contao\FrontendUser;
use Contao\MemberModel;
use Contao\PageModel;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* @Hook("setNewPassword")
*/
final class SetNewPasswordListener
{
public function __invoke($member): void
{
if ($member instanceof MemberModel) {
// We only want to reset "pwChange" if the user changed their password with the change-password module.
// If this hook is called within the save_callback (backend mask), $member is an instance of Database\Result.
$member->pwChange = '';
$member->save();
}
}
}
+10 -21
View File
@@ -1,38 +1,27 @@
services:
terminal42_password_validation.listener.regexp:
class: Terminal42\PasswordValidationBundle\EventListener\PasswordRegexpListener
public: true
Terminal42\PasswordValidationBundle\EventListener\PasswordRegexpListener:
arguments:
['@terminal42_password_validation.validator_manager', '@terminal42_password_validation.validator_configuration']
terminal42_password_validation.listener.history_log:
class: Terminal42\PasswordValidationBundle\EventListener\PasswordHistoryListener
public: true
Terminal42\PasswordValidationBundle\EventListener\PasswordHistoryListener:
arguments:
['@terminal42_password_validation.validator_configuration', '@security.encoder_factory', '@database_connection']
terminal42_password_validation.listener.invalid_attempts:
class: Terminal42\PasswordValidationBundle\EventListener\InvalidAttemptsListener
public: true
arguments:
['@terminal42_password_validation.validator_configuration', '@contao.framework']
terminal42_password_validation.listener.password_change:
class: Terminal42\PasswordValidationBundle\EventListener\PasswordChangeListener
public: true
Terminal42\PasswordValidationBundle\EventListener\PasswordChangeListener:
arguments:
['@terminal42_password_validation.validator_configuration']
terminal42_password_validation.listener.no_password_change_page_warning:
class: Terminal42\PasswordValidationBundle\EventListener\NoPwChangePageWarning
public: true
Terminal42\PasswordValidationBundle\EventListener\NoPwChangePageWarning:
arguments:
['@terminal42_password_validation.validator_configuration']
terminal42_password_validation.listener.password_change_frontend:
class: Terminal42\PasswordValidationBundle\EventListener\PasswordChangeFrontendListener
public: true
Terminal42\PasswordValidationBundle\EventListener\RedirectToPasswordChangePageListener:
arguments:
- '@contao.framework'
- '@security.token_storage'
- '@security.authentication.trust_resolver'
- '@contao.routing.scope_matcher'
tags:
- kernel.event_listener
Terminal42\PasswordValidationBundle\EventListener\SetNewPasswordListener: ~
-56
View File
@@ -13,60 +13,4 @@ declare(strict_types=1);
use Terminal42\PasswordValidationBundle\Model\PasswordHistory;
$GLOBALS['TL_HOOKS']['addCustomRegexp'][] =
['terminal42_password_validation.listener.regexp', 'onAddCustomRegexp'];
$GLOBALS['TL_HOOKS']['setNewPassword'][] =
['terminal42_password_validation.listener.history_log', 'onSetNewPassword'];
$GLOBALS['TL_HOOKS']['getPageLayout'][] =
['terminal42_password_validation.listener.password_change_frontend', 'onGetPageLayout'];
$GLOBALS['TL_HOOKS']['setNewPassword'][] =
['terminal42_password_validation.listener.password_change_frontend', 'onSetNewPassword'];
$GLOBALS['TL_HOOKS']['checkCredentials'][] =
['terminal42_password_validation.listener.invalid_attempts', 'onCheckCredentials'];
$GLOBALS['TL_HOOKS']['postLogin'][] =
['terminal42_password_validation.listener.invalid_attempts', 'onPostLogin'];
$GLOBALS['TL_HOOKS']['postLogin'][] =
['terminal42_password_validation.listener.password_change', 'onPostLogin'];
$GLOBALS['TL_HOOKS']['getSystemMessages'][] =
['terminal42_password_validation.listener.no_password_change_page_warning', 'onGetSystemMessages'];
$GLOBALS['TL_MODELS']['tl_password_history'] = PasswordHistory::class;
$GLOBALS['NOTIFICATION_CENTER']['NOTIFICATION_TYPE'] = array_merge(
(array) $GLOBALS['NOTIFICATION_CENTER']['NOTIFICATION_TYPE'],
[
'password_validation' => [
'account_disabled' => [
'recipients' => [
'admin_email',
'user_email',
],
'email_text' => [
'username',
'user_class',
'user_*',
],
'email_html' => [
'username',
'user_class',
'user_*',
],
'email_sender_name' => [
'admin_email',
],
'email_sender_address' => [
'admin_email',
],
'email_recipient_cc' => [
'admin_email',
],
'email_recipient_bcc' => [
'admin_email',
],
'email_replyTo' => [
'admin_email',
],
],
],
]
);
+1 -17
View File
@@ -12,12 +12,11 @@ declare(strict_types=1);
*/
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Contao\CoreBundle\Exception\PaletteNotFoundException;
use Contao\CoreBundle\DataContainer\PaletteNotFoundException;
$paletteManipulator = PaletteManipulator::create()
->addLegend('pwChangeLegend', 'publish_legend', PaletteManipulator::POSITION_BEFORE)
->addField('pwChangePage', 'pwChangeLegend', PaletteManipulator::POSITION_APPEND)
->addField('nc_account_disabled', 'pwChangeLegend', PaletteManipulator::POSITION_APPEND)
;
try {
@@ -38,18 +37,3 @@ $GLOBALS['TL_DCA']['tl_page']['fields']['pwChangePage'] = [
'sql' => "int(10) unsigned NOT NULL default '0'",
'relation' => ['type' => 'hasOne', 'load' => 'eager'],
];
$GLOBALS['TL_DCA']['tl_page']['fields']['nc_account_disabled'] = [
'label' => &$GLOBALS['TL_LANG']['tl_page']['nc_account_disabled'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => static function () {
return \Contao\System::getContainer()
->get('database_connection')
->executeQuery('SELECT id,title FROM tl_nc_notification WHERE type=\'account_disabled\' ORDER BY title')
->fetchAll(PDO::FETCH_KEY_PAIR);
},
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
'sql' => "int(10) unsigned NOT NULL default '0'",
'relation' => ['type' => 'hasOne', 'load' => 'lazy', 'table' => 'tl_nc_notification'],
];
-2
View File
@@ -12,5 +12,3 @@ declare(strict_types=1);
*/
$GLOBALS['TL_DCA']['tl_user']['fields']['password']['eval']['rgxp'] = 'terminal42_password_validation';
$GLOBALS['TL_DCA']['tl_user']['fields']['password']['save_callback'][] =
['terminal42_password_validation.listener.history_log', 'onBackendSaveCallback'];