Problem/Motivation

At present if an attempt to authenticate via the user login form is blocked by flood control, nothing useful is logged.

The form gets an error set on it, but the response is still a 200 which means that access log entries cannot be identified by anyone interested in whether a site is being brute-forced.

e.g. core/modules/user/src/Form/UserLoginForm.php

      if (!$this->flood->isAllowed('user.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
        $form_state->set('flood_control_triggered', 'ip');
        return;
      }

...and then:

      if ($flood_control_triggered = $form_state->get('flood_control_triggered')) {
        if ($flood_control_triggered == 'user') {
          $form_state->setErrorByName('name', $this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => $this->url('user.pass')]));
        }
        else {
          // We did not find a uid, so the limit is IP-based.
          $form_state->setErrorByName('name', $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => $this->url('user.pass')]));
        }
      }

Whereas in core/modules/user/src/Controller/UserAuthenticationController.php

    if (!$this->flood->isAllowed('user.failed_login_ip', $flood_config->get('ip_limit'), $flood_config->get('ip_window'))) {
      throw new AccessDeniedHttpException('Access is blocked because of IP based flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);
    }

...so that yields a 403 in access logs.

At present we see something like this when the login form is brute-forced:

==> syslog <==
Jul  3 15:57:53 xenial-php drupal: http://drupal8x.xp|1530633473|user|10.0.3.1|http://drupal8x.xp/user/login||0||Login attempt failed for foo.

==> access.log <==
drupal8x.xp 10.0.3.1 - - [03/Jul/2018:15:57:53 +0000] "POST /user/login HTTP/1.1" 200 11726

..repeated a few times, until flood control kicks in, at which point we just see:

==> access.log <==
drupal8x.xp 10.0.3.1 - - [03/Jul/2018:15:57:55 +0000] "POST /user/login HTTP/1.1" 200 11726
drupal8x.xp 10.0.3.1 - - [03/Jul/2018:15:57:57 +0000] "POST /user/login HTTP/1.1" 200 11798
drupal8x.xp 10.0.3.1 - - [03/Jul/2018:15:57:58 +0000] "POST /user/login HTTP/1.1" 200 11798

Proposed resolution

Anyone wanting to gather threat intelligence from a Drupal site's logs would ideally want specific log entries to say "a login from this IP was blocked by flood control".

There may be performance reasons why we wouldn't want to make these log entries (e.g. it may represent a DoS risk especially on sites which are using the default database logging).

Filing this issue to discuss and work on potentially a couple of changes (follow up issues can be filed if appropriate):

* Respond with a 403 (consistently) when a login attempt has been blocked due to flood control limits.
* Log these blocked login attempts to logger (/ watchdog if we backport) - possibly as an opt-in change in order to avoid possible performance regressions.

Remaining tasks

* Discuss proposed changes.
* Implement them.

User interface changes

* Perhaps an option to turn flood control / brute force logging on and off?
* More / different log entries, if those count as UI.

API changes

None?

Data model changes

None?

CommentFileSizeAuthor
#113 Screen Shot 2020-06-09 at 8.24.42 am.png12.49 KBamjad1233
#112 2983395-112.patch34.81 KBmcdruid
#112 interdiff-2983395-109-112.txt1.46 KBmcdruid
#109 2983395-109.patch34.78 KBmcdruid
#109 interdiff-2983395-108-109.txt723 bytesmcdruid
#108 2983395-108.patch34.78 KBmcdruid
#108 interdiff-2983395-95-108.txt1.31 KBmcdruid
#105 Screen Shot 2020-05-29 at 9.31.16 am.png101.97 KBamjad1233
#103 Selection_002.png34.82 KBmcdruid
#103 Selection_001.png57.15 KBmcdruid
#101 Screen Shot 2020-05-18 at 3.59.32 PM.png35.59 KBnarendra.rajwar27
#95 2983395-95.patch34 KBmcdruid
#94 2983395-94.patch34.17 KBnarendra.rajwar27
#94 interdiff_2983395-92-94.txt4.05 KBnarendra.rajwar27
#92 interdiff_2983395_90-91.txt684 bytesnarendra.rajwar27
#92 2983395-91.patch34.17 KBnarendra.rajwar27
#90 interdiff_2983395_86-90.txt3 KBnarendra.rajwar27
#90 2983395-90.patch34.11 KBnarendra.rajwar27
#86 interdiff_82-86.txt574 bytesswatichouhan012
#86 2983395-86.patch34.13 KBswatichouhan012
#85 interdiff_84-85.txt12.18 KBprabha1997
#85 2983395-85.patch20.79 KBprabha1997
#82 2983395-82.patch34.11 KBmcdruid
#82 interdiff-2983395-79-82.txt617 bytesmcdruid
#79 interdiff-2983395-74-79.txt5.56 KBmcdruid
#79 2983395-79.patch34.08 KBmcdruid
#74 interdiff-2983395-73-74.txt622 bytesmcdruid
#74 2983395-74.patch34.79 KBmcdruid
#73 interdiff-2983395-72-73.txt414 bytesmcdruid
#73 2983395-73.patch34.8 KBmcdruid
#72 2983395-72.patch34.8 KBandypost
#72 interdiff.txt2.6 KBandypost
#70 2983395-70.patch34.86 KBandypost
#70 interdiff.txt3.51 KBandypost
#66 interdiff-2983395-63-66.txt11.41 KBmcdruid
#66 2983395-66.patch34.88 KBmcdruid
#63 interdiff-2983395-62-63.txt10.31 KBmcdruid
#63 2983395-63.patch31.12 KBmcdruid
#62 interdiff-2983395-57-62.txt9.26 KBmcdruid
#62 2983395-62.patch28.64 KBmcdruid
#57 interdiff-2983395-54-57.txt3.88 KBmcdruid
#57 2983395-57.patch27.97 KBmcdruid
#55 interdiff-2983395-53-54.txt2.81 KBmcdruid
#54 interdiff-2983395-53-54.patch2.81 KBmcdruid
#54 2983395-54.patch27.98 KBmcdruid
#53 2983395-53.patch27.31 KBmcdruid
#50 interdiff-2983395-49-50.txt12.56 KBmcdruid
#50 2983395-50.patch27.26 KBmcdruid
#49 interdiff-2983395-48-49.txt2.79 KBmcdruid
#49 2983395-49.patch18.29 KBmcdruid
#48 2983395-diff-46-48.txt4.15 KBvijaycs85
#48 2983395-48.patch17.76 KBvijaycs85
#46 interdiff-2983395-45-46.txt2.4 KBmcdruid
#46 2983395-46.patch17.75 KBmcdruid
#45 interdiff-2983395-43-45.txt1.73 KBmcdruid
#45 2983395-45.patch17.42 KBmcdruid
#44 interdiff-2983395-43-44.txt1.74 KBmcdruid
#44 2983395-44.patch17.56 KBmcdruid
#43 interdiff-2983395-42-43.txt16.22 KBmcdruid
#43 2983395-43.patch17.72 KBmcdruid
#42 interdiff-2983395-39-42.txt1.87 KByogeshmpawar
#42 2983395-42.patch7.41 KByogeshmpawar
#39 2983395-39.patch7.42 KBmcdruid
#36 2983395-36.patch7.12 KBmcdruid
#34 2983395-34.patch7.11 KBmcdruid
#31 2983395-17.interdiff.txt870 bytessunnygambino
#17 interdiff-2983395-15-17.txt1001 bytesmcdruid
#17 2983395-17.patch6.87 KBmcdruid
#15 interdiff-2983395-11-15.txt3.09 KBmcdruid
#15 2983395-15.patch6.67 KBmcdruid
#12 interdiff-2983395-10-11.txt2.09 KBmcdruid
#11 interdiff-2983395-10-11.patch2.09 KBmcdruid
#11 2983395-11.patch6.88 KBmcdruid
#10 interdiff-2983395-8-10.txt1.96 KBmcdruid
#10 2983395-10.patch6.81 KBmcdruid
#8 interdiff-2983395-6-8.txt2.26 KBmcdruid
#8 2983395-8.patch5.53 KBmcdruid
#6 interdiff-2983395-4-6.txt4.9 KBmcdruid
#6 2983395-6.patch5.34 KBmcdruid
#4 2983395-4.patch2.7 KBmcdruid

Comments

mcdruid created an issue. See original summary.

mcdruid’s picture

Issue summary: View changes
Issue tags: +DevDaysLisbon
mcdruid’s picture

Issue summary: View changes
mcdruid’s picture

Status: Active » Needs review
StatusFileSize
new2.7 KB

I'd be surprised if we ended up implementing the suggested changes exactly like this, but here's an initial patch to get the ball rolling.

This patch changes the behaviour in \Drupal\user\Form\UserLoginForm::validateFinal - when flood control has been triggered - in the following ways:

* Dispenses with setting the form errors (as the form will not be re-displayed).
* Throws an AccessDeniedHttpException so that we record a 403 in the logs.
* Logs a message to record the IP which was blocked, and the user account the login attempt was for, if applicable.
* Uses drupal_set_message() to actually display some details about why the request was blocked.

I'm probably missing something, but the $message sent with the AccessDeniedHttpException doesn't seem to go anywhere useful which is why I've resorted to a dsm on top.

So we end up with something like this in the logs:

==> access.log <==
drupal8x.xphp 10.0.3.1 - - [10/Jul/2018:15:32:08 +0000] "POST /user/login HTTP/1.1" 403 10154

==> syslog <==
Jul 10 15:32:08 xphp drupal: https://drupal8x.xphp|1531236728|user|10.0.3.1|https://drupal8x.xphp/user/login|https://drupal8x.xphp/user/login|0||Flood control blocked login attempt for admin from 10.0.3.1.
Jul 10 15:32:08 xphp drupal: https://drupal8x.xphp|1531236728|access denied|10.0.3.1|https://drupal8x.xphp/user/login|https://drupal8x.xphp/user/login|0||/user/login

This makes me think it may be better to implement a more specific AccessDeniedHttpException which might distil the two log entries into one (and could possibly even display the $message without a dsm if that makes sense.)

These changes will almost certainly cause test failures; I'll address that once we've seen exactly what's broken.

Status: Needs review » Needs work

The last submitted patch, 4: 2983395-4.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new5.34 KB
new4.9 KB

Fixed some borked indentation and the failing tests, and changed the deprecated drupal_set_message() calls to use the Messenger service instead.

At present this removes the formatPlural() in the messages shown when user-based flood control is triggered. It would be quite easy to add back in again, but I'm personally not sure it's necessary... Happy to hear other opinions.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

mcdruid’s picture

StatusFileSize
new5.53 KB
new2.26 KB

Added settings checks around the logging in order to allow sites to opt out of it if - for example - it's having a negative performance impact. I think this is only really likely to be the case for database logging.

I've not added examples to (default.)settings.php but we could do.

anavarre’s picture

Status: Needs review » Needs work
Issue tags: +scalability

Tagging with scalability since it can impact DBlog.

I'd be ideal to test for the HTTP response code 403 and ensure the log message actually made it through.


+++ b/core/modules/user/src/Form/UserLoginForm.php
@@ -220,11 +233,19 @@ public function validateFinal(array &$form, FormStateInterface $form_state) {
+          $this->messenger->addError($this->t('Too many failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => $this->url('user.pass')]));
+          if (Settings::get('log_user_flood_user', true)) {
+            $this->logger('user')->notice('Flood control blocked login attempt for %user from %ip.', ['%user' => $form_state->getValue('name'), '%ip' => $this->getRequest()->getClientIp()]);
+          }
+          throw new AccessDeniedHttpException('Access is blocked because of flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);
...
+          $this->messenger->addError($this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => $this->url('user.pass')]));
+          if (Settings::get('log_user_flood_ip', true)) {
+            $this->logger('user')->notice('Flood control blocked login attempt from %ip.', ['%ip' => $this->getRequest()->getClientIp()]);
+          }
+          throw new AccessDeniedHttpException('Access is blocked because of flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);

Would it be possible you try and refactor this code such as you don't have to duplicate almost entirely those lines?

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new6.81 KB
new1.96 KB

I don't disagree about the duplication, but so far this patch is pretty much making small tweaks to the logic that's already there.

For now, here's a new patch which adds the suggested checks to the tests:

* Check there's a 403 when flood control has blocked a login attempt.
* Check the logger/watchdog messages when flood control has blocked a login attempt.

For testing the logging, there are a couple of patterns in existing tests; both of which involve installing the dblog module during the test.

One approach is then setting up an admin user with appropriate permissions, logging that user in, and doing a GET request for 'admin/reports/dblog' to see if the log entries appear in the output (see, e.g. \Drupal\Tests\contact\Functional\ContactSitewideTest::testAutoReply). The other option is to check directly in the database (see e.g. \Drupal\system\Tests\Form\StorageTest::testImmutableFormLegacyProtection). (I believe there's actually a bug in the query-based test there though, for which I'll file a follow-up).

The db query seems quite a bit more lightweight, so that's what I've gone with here... but you could argue that as the tests here extend BrowserTestBase, that doing the check with a GET request to 'admin/reports/dblog' would be more appropriate.

mcdruid’s picture

StatusFileSize
new6.88 KB
new2.09 KB

I think this is a better way to test the logging; specifically checking what the last log message was from the user module, rather than just a boolean to indicate whether a particular message is there in the table at all.

mcdruid’s picture

StatusFileSize
new2.09 KB

oops, messed up the file extension on the last interdiff

Status: Needs review » Needs work

The last submitted patch, 11: interdiff-2983395-10-11.patch, failed testing. View results

mcdruid’s picture

Status: Needs work » Needs review

Hmm - tried to cancel the test for the messed up interdiff... back to NR for the actual patch in #11

mcdruid’s picture

StatusFileSize
new6.67 KB
new3.09 KB

Small tweaks to reduce duplication.. along the lines of the suggestion from anavarre.

borisson_’s picture

Status: Needs review » Needs work

I think that getting it from the database is a good idea in the test, it's not because we are in a browsertest that we need to do everything in the browser, it's not the job of this test to test the dblog UI.

I would like to rtbc this, but I found some nits to pick.

  1. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -45,6 +49,13 @@ class UserLoginForm extends FormBase {
    +    /**
    +     * The messenger.
    +     *
    +     * @var \Drupal\Core\Messenger\MessengerInterface
    +     */
    +    protected $messenger;
    

    The indentation of this looks off, it should be 2 spaces.

  2. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -57,11 +68,12 @@ class UserLoginForm extends FormBase {
    -  public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer) {
    +  public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer, MessengerInterface $messenger) {
    

    With this now being injected, the documentation of the __construct method should be updated as well.

  3. +++ b/core/modules/user/tests/src/Functional/UserLoginTest.php
    @@ -12,6 +12,13 @@
    +  /**
    +   * Modules to install.
    +   *
    +   * @var array
    +   */
    

    {@inheritdoc} is sufficient.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new6.87 KB
new1001 bytes

Thanks for the review.

I've fixed the problems you pointed out in UserLoginForm.php

However, I've left the comment about modules, as this seems to be widespread in tests throughout core:

drupal-8.x$ grep -rn 'Modules to install.' *
core/tests/Drupal/KernelTests/Core/Entity/EntityReferenceFieldTest.php:58:   * Modules to install.
core/tests/Drupal/KernelTests/Core/ParamConverter/EntityConverterLatestRevisionTest.php:19:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Extension/ModuleInstallerTest.php:19:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Theme/StableThemeTest.php:15:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Field/Entity/BaseFieldOverrideTest.php:18:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Config/ConfigEntityNormalizeTest.php:15:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Config/ConfigOverridesPriorityTest.php:17:   * Modules to install.
core/tests/Drupal/KernelTests/Core/Config/ConfigModuleOverridesTest.php:15:   * Modules to install.
core/modules/rest/src/Tests/RESTTestBase.php:64:   * Modules to install.
core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php:154:   * Modules to install.
core/modules/rest/tests/src/Functional/Views/StyleSerializerTest.php:35:   * Modules to install.
core/modules/rest/tests/src/Functional/ResourceTestBase.php:92:   * Modules to install.
core/modules/rest/tests/src/Functional/ResourceTest.php:22:   * Modules to install.
core/modules/menu_ui/tests/src/Functional/MenuUiContentModerationTest.php:18:   * Modules to install.
core/modules/media/tests/src/Functional/MediaTemplateSuggestionsTest.php:15:   * Modules to install.
core/modules/media/tests/src/Kernel/MediaKernelTestBase.php:21:   * Modules to install.
core/modules/media/tests/src/Kernel/MediaTranslationTest.php:16:   * Modules to install.
core/modules/forum/tests/src/Kernel/ForumValidationTest.php:17:   * Modules to install.
core/modules/config/tests/src/Functional/ConfigImportInstallProfileTest.php:22:   * Modules to install.
core/modules/config/tests/src/Functional/ConfigImportUploadTest.php:25:   * Modules to install.
core/modules/config/tests/src/Functional/ConfigLanguageOverrideWebTest.php:17:   * Modules to install.
core/modules/config/tests/src/Functional/ConfigImportUITest.php:18:   * Modules to install.
core/modules/options/tests/src/Kernel/Views/FileViewsDataTest.php:18:   * Modules to install.
core/modules/color/tests/src/Functional/ColorConfigSchemaTest.php:15:   * Modules to install.
core/modules/color/tests/src/Functional/ColorTest.php:16:   * Modules to install.
core/modules/content_moderation/tests/src/Functional/ContentModerationWorkflowTypeTest.php:15:   * Modules to install.
core/modules/content_moderation/tests/src/Kernel/ModerationStateWidgetTest.php:22:   * Modules to install.
core/modules/content_moderation/tests/src/Kernel/ContentModerationPermissionsTest.php:17:   * Modules to install.
core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowTypeApiTest.php:25:   * Modules to install.
core/modules/field_ui/src/Tests/ManageFieldsTest.php:25:   * Modules to install.
core/modules/field_ui/src/Tests/FieldUIDeleteTest.php:21:   * Modules to install.
core/modules/field_ui/src/Tests/ManageDisplayTest.php:23:   * Modules to install.
core/modules/field_ui/tests/src/Functional/FieldUIIndentationTest.php:15:   * Modules to install.
core/modules/field_ui/tests/src/Functional/FieldUIRouteTest.php:17:   * Modules to install.
core/modules/field_ui/tests/src/Kernel/EntityDisplayTest.php:27:   * Modules to install.
core/modules/field_ui/tests/src/Kernel/EntityFormDisplayTest.php:19:   * Modules to install.
core/modules/action/tests/src/Functional/ConfigurationTest.php:17:   * Modules to install.
core/modules/action/tests/src/Functional/ActionUninstallTest.php:16:   * Modules to install.
core/modules/action/tests/src/Functional/BulkFormTest.php:17:   * Modules to install.
core/modules/action/tests/src/Functional/ActionListTest.php:15:   * Modules to install.
core/modules/editor/tests/src/Functional/EditorDialogAccessTest.php:17:   * Modules to install.
core/modules/book/tests/src/Functional/Views/BookRelationshipTest.php:25:   * Modules to install.
core/modules/book/tests/src/Functional/BookTest.php:19:   * Modules to install.
core/modules/book/tests/src/Functional/BookContentModerationTest.php:19:   * Modules to install.
core/modules/book/tests/src/Functional/BookBreadcrumbTest.php:15:   * Modules to install.
core/modules/book/tests/src/Functional/BookInstallTest.php:16:   * Modules to install.
core/modules/aggregator/src/Tests/AggregatorTestBase.php:26:   * Modules to install.
core/modules/aggregator/tests/src/Functional/FeedLanguageTest.php:18:   * Modules to install.
core/modules/aggregator/tests/src/Functional/AggregatorRenderingTest.php:16:   * Modules to install.
core/modules/aggregator/tests/src/Functional/DeleteFeedTest.php:13:   * Modules to install.
core/modules/aggregator/tests/src/Functional/AggregatorTestBase.php:23:   * Modules to install.
core/modules/aggregator/tests/src/Functional/ImportOpmlTest.php:13:   * Modules to install.
core/modules/aggregator/tests/src/Kernel/FeedValidationTest.php:16:   * Modules to install.
core/modules/aggregator/tests/src/Kernel/Views/IntegrationTest.php:20:   * Modules to install.
core/modules/breakpoint/tests/src/Kernel/BreakpointDiscoveryTest.php:15:   * Modules to install.
core/modules/user/tests/src/Functional/UserLoginTest.php:16:   * Modules to install.
core/modules/user/tests/src/Functional/UserLoginHttpTest.php:27:   * Modules to install.
core/modules/path/tests/src/Functional/PathContentModerationTest.php:20:   * Modules to install.
core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php:21:   * Modules to install.
core/modules/field/src/Tests/EntityReference/EntityReferenceAdminTest.php:22:   * Modules to install.
core/modules/field/tests/src/Functional/EntityReference/EntityReferenceIntegrationTest.php:44:   * Modules to install.
core/modules/field/tests/src/Functional/EntityReference/EntityReferenceFieldDefaultValueTest.php:21:   * Modules to install.
core/modules/field/tests/src/Kernel/EntityReference/Views/EntityReferenceRelationshipTest.php:40:   * Modules to install.
core/modules/field/tests/src/Kernel/EntityReference/EntityReferenceItemTest.php:38:   * Modules to install.
core/modules/node/tests/src/Functional/NodeActionsConfigurationTest.php:16:   * Modules to install.
core/modules/comment/src/Tests/CommentTestBase.php:28:   * Modules to install.
core/modules/comment/src/Tests/Views/CommentTestBase.php:25:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentUninstallTest.php:20:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentTranslationUITest.php:46:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentTestBase.php:22:   * Modules to install.
core/modules/comment/tests/src/Functional/Views/DefaultViewRecentCommentsTest.php:21:   * Modules to install.
core/modules/comment/tests/src/Functional/Views/CommentTestBase.php:18:   * Modules to install.
core/modules/comment/tests/src/Functional/Views/NodeCommentsTest.php:13:   * Modules to install.
core/modules/comment/tests/src/Functional/Views/WizardTest.php:20:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentEntityTest.php:20:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentBlockTest.php:16:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentLanguageTest.php:21:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentNodeAccessTest.php:18:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentRssTest.php:20:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentBookTest.php:21:   * Modules to install.
core/modules/comment/tests/src/Functional/CommentActionsTest.php:16:   * Modules to install.
core/modules/comment/tests/src/Kernel/CommentStringIdEntitiesTest.php:17:   * Modules to install.
core/modules/comment/tests/src/Kernel/CommentValidationTest.php:18:   * Modules to install.
core/modules/comment/tests/src/Kernel/CommentDefaultFormatterCacheTagsTest.php:25:   * Modules to install.
core/modules/comment/tests/src/Kernel/CommentFieldAccessTest.php:29:   * Modules to install.
core/modules/content_translation/tests/src/Functional/ContentTranslationMetadataFieldsTest.php:27:   * Modules to install.
core/modules/system/tests/src/Functional/Render/AjaxPageStateTest.php:15:   * Modules to install.
core/modules/system/tests/src/Functional/System/ResponseGeneratorTest.php:16:   * Modules to install.
core/modules/block/src/Tests/BlockTestBase.php:21:   * Modules to install.
core/modules/block/tests/src/Functional/BlockUiTest.php:16:   * Modules to install.
core/modules/block/tests/src/Functional/BlockAdminThemeTest.php:15:   * Modules to install.
core/modules/block/tests/src/Functional/NonDefaultBlockAdminTest.php:15:   * Modules to install.
core/modules/block/tests/src/Functional/BlockTestBase.php:14:   * Modules to install.
core/modules/block/tests/src/Functional/Views/DisplayBlockTest.php:27:   * Modules to install.
core/modules/block/tests/src/Functional/BlockRenderOrderTest.php:16:   * Modules to install.
core/modules/block/tests/src/Functional/BlockHiddenRegionTest.php:21:   * Modules to install.
core/modules/block/tests/src/Functional/BlockInvalidRegionTest.php:17:   * Modules to install.
core/modules/block/tests/src/Functional/NewDefaultThemeBlocksTest.php:15:   * Modules to install.
core/modules/block/tests/src/Functional/BlockCacheTest.php:16:   * Modules to install.
core/modules/block/tests/src/Functional/BlockLanguageCacheTest.php:16:   * Modules to install.
core/modules/block/tests/src/Functional/BlockLanguageTest.php:22:   * Modules to install.
core/modules/block/tests/src/Functional/BlockHtmlTest.php:15:   * Modules to install.
core/modules/block/tests/src/Functional/BlockTemplateSuggestionsTest.php:16:   * Modules to install.
core/modules/block/tests/src/Functional/BlockSystemBrandingTest.php:13:   * Modules to install.
core/modules/block/tests/src/Functional/BlockXssTest.php:20:   * Modules to install.
core/modules/block/tests/src/Functional/BlockHookOperationTest.php:15:   * Modules to install.
core/modules/block/tests/src/Functional/BlockFormInBlockTest.php:16:   * Modules to install.
core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php:19:   * Modules to install.
core/modules/block/tests/src/Kernel/BlockStorageUnitTest.php:20:   * Modules to install.
core/modules/block_place/tests/src/Functional/BlockPlaceTest.php:16:   * Modules to install.
core/modules/responsive_image/src/Tests/ResponsiveImageFieldUiTest.php:19:   * Modules to install.
core/modules/ban/tests/src/Functional/IpAddressBlockingTest.php:17:   * Modules to install.
core/modules/file/tests/src/Functional/Views/RelationshipUserFileDataTest.php:20:   * Modules to install.
core/modules/image/tests/src/Kernel/Views/RelationshipUserImageDataTest.php:21:   * Modules to install.
core/modules/image/tests/src/Kernel/Views/ImageViewsDataTest.php:18:   * Modules to install.

You could - of course - argue that doesn't mean it's right :) but I've left it for the sake of consistency.

borisson_’s picture

Status: Needs review » Reviewed & tested by the community

You could - of course - argue that doesn't mean it's right :) but I've left it for the sake of consistency.

As daniel always says: sanity > consistency. In this case it doesn't really matter though.

tatarbj’s picture

Hi @mcdruid,
I'm happy to see this issue in RTBC, but was just wondering would it be a nice approach to backport it to D7 in a separated issue? If so, i'm glad to help you on the way :)
Bests,
Balazs.

tatarbj’s picture

Issue tags: +Needs backport to D7
tatarbj’s picture

I've created the d7 backport issue with a similar solution to propose - it needs some valid d7 related log entries and porting this solution that seems pretty easy as only watchdog should be called there. But more details under #2989985: User module's flood controls should do better logging [D7 backport]

mcdruid’s picture

Thanks for the D7 backport follow-up Balazs; I was thinking that would be a good idea.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/user/src/Form/UserLoginForm.php
@@ -220,12 +235,19 @@ public function validateFinal(array &$form, FormStateInterface $form_state) {
+          if (Settings::get('log_user_flood_user', true)) {
...
+          if (Settings::get('log_user_flood_ip', true)) {

Is the granularity that useful here?

We also have authentication by other means too - should we make the logging consistent? I.e. login via REST and \Drupal\user\Controller\UserAuthenticationController::login() which calls \Drupal\user\Controller\UserAuthenticationController::floodControl()

In a joined up world there might be a user flood control service that both the form and UserAuthenticationController use that does the logging.

mcdruid’s picture

Is the granularity that useful here?

Good point; no it's not.

In a joined up world there might be a user flood control service that both the form and UserAuthenticationController use that does the logging.

That sounds like a good idea, but I could do with a shove in the right direction in terms of where the class(es) would live and what they'd need to extend / implement.

I got as far as putting a FloodControl.php in core/modules/user/src but then realised I don't think I want to implement FloodInterface there. Do we want a new interface (UserFloodControlInterface?) which extends FloodInterface and then a new class which implements that perhaps?

anavarre’s picture

Priority: Normal » Major

Due to the impact this issue might have when you're being brute-forced, realistically this is major.

tatarbj’s picture

fgm’s picture

Regarding the answer code, I feel like a 429 might be more appropriate than just a 403: this is about flood control, and 429 means "Too Many Requests", which seems a perfect fit.

RFC 6585 ( https://tools.ietf.org/html/rfc6585#section-4 ) describes it as "too many requests in a given amount of time"

The interesting bit is that it allows the response to contain an explanation, and defines the use of the Retry-After header to specify for how long the account is blocked.

mcdruid’s picture

@fgm responding with 429 sounds like an interesting suggestion!

However, wouldn't that be better as a follow-up / separate issue? as this one's really about logging (although to be fair we seem to have strayed from that path quite a bit ;)

mcdruid’s picture

Adding notes from a discussion in #contribute (slack):

mcdruid
@alexpott I'm looking again at https://www.drupal.org/project/drupal/issues/2983395#comment-12722869 but not sure if I'm going in completely the wrong direction

I've got this in user.services.yml

  user.flood_control:
    class: Drupal\user\FloodControl
    decorates: flood
    arguments: ['@user.flood_control.inner', '@request_stack']

and I believe I've got that working and from there should be able to get the REST code using the same service
does that seem sane, or did you have something entirely different in mind?

alexpott
@mcdruid I think maybe a better architecture is a flood service that has a storage and fire events on flood actions. It could provide an event listener that logs that is only registered if your configure flood logging.
That way anything that want’s to do something in reaction to a flood event can.
And we don’t have to predict what that is.

tatarbj’s picture

sunnygambino’s picture

Status: Needs work » Needs review
StatusFileSize
new870 bytes

Hello Guys,
The HTTP 429 status code is used to be on the network layer. This is meaning that we should use it in the application layer , what is Drupal in this context. This status code is used by network services, servers, APIs etc... Logic should be implemented in the server application's config files. Don't do it in the application layer's logic.

  1. Most important rule is that this type of HTTP response MUST NOT be cached.
  2. HTTP response headers should use Retry-After

I think it is not the best idea to use. The good old 403 is more than enough.

I think we should take a bit care about the following sources:
https://httpstatuses.com/429
https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429
RFC Standard: https://tools.ietf.org/html/rfc6585#section-4

I also attached an interdiff for patch #17. This is just simply changing the HTTP resonse global back to 403.

I hope I was able to help to move this issue forward

#ContributionWeekend2019

sunnygambino’s picture

Version: 8.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

mcdruid’s picture

StatusFileSize
new7.11 KB

Rerolled patch #17 for 8.8.x

I'll put this back to "Needs work" based on #23 / #29 but will leave in NR to see if tests pass.

Status: Needs review » Needs work

The last submitted patch, 34: 2983395-34.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new7.12 KB

Another reroll, and back to NR for tests.

This will need to go back to "Needs work" based on #23 / #29 but trying to get tests to pass again first.

mcdruid’s picture

Noting also that the codesniffer patch wants to change s/true/TRUE in two places in the patch e.g.:

-          if (Settings::get('log_user_flood_user', true)) {                       
+          if (Settings::get('log_user_flood_user', TRUE)) {                       

-          if (Settings::get('log_user_flood_ip', true)) {                         
+          if (Settings::get('log_user_flood_ip', TRUE)) {

Status: Needs review » Needs work

The last submitted patch, 36: 2983395-36.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new7.42 KB

I think it's a few deprecations which were causing this to fail.

I've hopefully fixed those (and the codesniffer s/true/TRUE).

Should go back to "Needs work" based on #23 / #29 but trying to get tests to pass again first.

mcdruid’s picture

Ok good.

What I really need now is one or more examples I could learn from in order to implement this suggestion from @alexpott:

In a joined up world there might be a user flood control service that both the form and UserAuthenticationController use that does the logging.

I think maybe a better architecture is a flood service that has a storage and fire events on flood actions. It could provide an event listener that logs that is only registered if your configure flood logging.
That way anything that want’s to do something in reaction to a flood event can.
And we don’t have to predict what that is.

I'm happy to do the leg work, but could do with a decent example if there's a service that does something similar. Any ideas?

yogeshmpawar’s picture

Assigned: Unassigned » yogeshmpawar
yogeshmpawar’s picture

Assigned: yogeshmpawar » Unassigned
StatusFileSize
new7.41 KB
new1.87 KB

Resolved the coding standard issues & added an interdiff.

mcdruid’s picture

StatusFileSize
new17.72 KB
new16.22 KB

I'd be surprised if this was "right" first time, but here's an attempt to implement the architecture outlined in #40 from @alexpott's comments.

This doesn't change UserAuthenticationController yet, but it hopefully shouldn't be too hard to adapt that to fit.

There are a couple of existing phpcs errors in the files this changes, but I've left those alone for now. Hopefully the patch doesn't introduce any new ones.

The format of what's being logged is not great initially; I'd aim to improve that, but wanted to get an idea of whether this is going in the right direction first.

mcdruid’s picture

StatusFileSize
new17.56 KB
new1.74 KB

Small tweak to fix a variable name that I'd somehow clobbered.

mcdruid’s picture

StatusFileSize
new17.42 KB
new1.73 KB

Oops got the variable name wrong again in one place.

Interdiff is against 43 as that shows what I'm trying to fix.

mcdruid’s picture

StatusFileSize
new17.75 KB
new2.4 KB

Small tweak to the logging when flood control blocks login for a particular username / uid.

vijaycs85’s picture

Overall it looks great. IMO, at minimum, we should have this patch to introducing&dispatching flood-related events, if not all. One major concern (split into multiple comments) below:

  1. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -2,14 +2,17 @@
    -use Drupal\Core\Flood\FloodInterface;
    ...
    +use Drupal\user\UserFloodControl;
    

    Probably a BC break?

  2. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -70,10 +76,11 @@ public function __construct(FloodInterface $flood, UserStorageInterface $user_st
    -      $container->get('flood'),
    +      $container->get('user.flood_control'),
    

    same here.

  3. +++ b/core/modules/user/src/UserFloodControl.php
    @@ -0,0 +1,94 @@
    +  public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL) {
    ...
    +    else {
    +      // Register flood control blocked login event.
    +      switch ($name) {
    

    If I get it right, we are adding this wrapper class *only* for this method. Why can't we do this in @flood? which would avoid the API changes.

vijaycs85’s picture

StatusFileSize
new17.76 KB
new4.15 KB

Checked comments again especially #29 which summarizes the preferred solution. I have updated the patch to reflect those requirements:

1. The new service ''UserFloodControl" is a stand-alone service not implementing FloodInterface which is poorly named storage interface. I would love to deprecate @flood and use flood.storage.* something for better DX but probably as follow up, if @alexpott agrees :)
2. Since most of the methods in flood storage are storage's responsibility, removed them from service and added storage() method which can be used for storage-related operations and avoid duplicate methods. This also means, if a storage implementation has a new method, it can be used.
3. Though UserLogin is marked @internal, should we do anything to inform we are changing the args?


Created a CR draft as we are introducing a new service and events.
mcdruid’s picture

StatusFileSize
new18.29 KB
new2.79 KB

Thanks @vijaycs85; I like passing $storage into the new service better.

As discussed, I think one thing we've missed so far is that the event can be dispatched without a proper $identifier set.

For example \Drupal\Core\Flood\DatabaseBackend::isAllowed() does:

  public function isAllowed($name, $threshold, $window = 3600, $identifier = NULL) {
    if (!isset($identifier)) {
      $identifier = $this->requestStack->getCurrentRequest()->getClientIp();
    }

As things stand, we need to add something similar to \Drupal\user\UserFloodControl::isAllowed() otherwise we can end up logging flood control blocking a login by IP without actually including that IP in the event that's dispatched.

Here's a new patch which adds that in; I've confirmed that this now logs the IP e.g.

https://drupal8x.xp|1563461274|user|10.0.3.1|https://drupal8x.xp/user/login|https://drupal8x.xp/user/login|0||Flood control blocked login attempt by IP: 10.0.3.1
mcdruid’s picture

StatusFileSize
new27.26 KB
new12.56 KB

As per the original suggestion in #23 here's a patch which uses the new UserFloodControl service in \Drupal\user\Controller\UserAuthenticationController.

One quirk I came across was that the controller uses two names for the different types of flood control (per user / per IP), but only one of these is shared with \Drupal\user\Form\UserLoginForm. The two names relate to essentially the same two different options though.

That's led to this in \Drupal\user\UserFloodControl:

      // Register flood control blocked login event.
      switch ($name) {
        case 'user.failed_login_ip':
          $event_name = UserEvents::FLOOD_BLOCKED_IP;
          break;

        case 'user.failed_login_user':
        case 'user.http_login':
          $event_name = UserEvents::FLOOD_BLOCKED_USER;
          break;
      }

Instead of doing that, we could replace user.http_login with user.failed_login_user within UserAuthenticationController which might be more consistent.

I am also not crazy about this code, which handles the different forms of "identifier" used by flood control depending on the different settings.

      if (strpos($details['identifier'], '-') !== FALSE) {
        list($uid, $ip) = explode('-', $details['identifier']);
        $this->logger->notice('Flood control blocked login attempt for uid %uid from %ip', ['%uid' => $uid, '%ip' => $ip]);
      }
      else {
        $this->logger->notice('Flood control blocked login attempt for uid %uid', ['%uid' => $details['identifier']]);
      }

There are several other ways we could do this which might be more elegant than the strpos() I threw in there to avoid an error that came up with the previous code when running Drupal\Tests\user\Functional\UserLoginHttpTest.

Both UserLoginHttpTest and UserLoginTest now check for the (watchdog) log messages emitted by flood control, and both pass locally. Let's see what the testbot thinks...

andypost’s picture

Status: Needs review » Needs work

Quick review - main question is backward compatibility for form class
Also needs work to fix coding standards - 5 https://www.drupal.org/pift-ci-job/1353387

  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -114,8 +114,8 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn
    -  public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats, LoggerInterface $logger) {
    -    $this->flood = $flood;
    +  public function __construct(UserFloodControl $user_flood_control, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats, LoggerInterface $logger) {
    +    $this->userFloodControl = $user_flood_control;
    

    Controllers are fine to override without BC https://www.drupal.org/core/d8-bc-policy#controllers

  2. +++ b/core/modules/user/src/EventSubscriber/UserFloodSubscriber.php
    @@ -0,0 +1,69 @@
    +   * An attempt to login has been blocked based on user name.
    +   */
    +  public function blockedUser(UserFloodEvent $floodEvent) {
    +    if (Settings::get('log_user_flood', TRUE)) {
    ...
    +   * An attempt to login has been blocked based on IP.
    +   */
    +  public function blockedIp(UserFloodEvent $floodEvent) {
    +    if (Settings::get('log_user_flood', TRUE)) {
    

    needs doc-block updates for arguments

  3. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -49,20 +52,23 @@ class UserLoginForm extends FormBase {
    -  public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer) {
    -    $this->flood = $flood;
    +  public function __construct(UserFloodControl $user_flood_control, UserStorageInterface $user_storage, UserAuthInterface $user_auth, RendererInterface $renderer, MessengerInterface $messenger) {
    +    $this->userFloodControl = $user_flood_control;
    ...
    +    $this->messenger = $messenger;
    

    I bet it no-go because no BC and this form very probably overriden in contrib/custom code

  4. +++ b/core/modules/user/user.services.yml
    @@ -71,3 +71,14 @@ services:
    +    arguments: ['user']
    \ No newline at end of file
    

    nit, needs newline

ghost of drupal past’s picture

Re #51.3 , if you want to see how it's done you can run git log -p --pickaxe-regex -S'__construct.*NULL' core/modules|grep deprecated|less and find examples like:

@trigger_error('Calling FileSystemForm::__construct() without the $file_system argument is deprecated in drupal:8.8.0. The $file_system argument will be required in drupal:9.0.0. See https://www.drupal.org/node/3039255', E_USER_DEPRECATED);

@trigger_error('Invoking the UpdateManager constructor without the module extension list parameter is deprecated in Drupal 8.8.0 and will no longer be supported in Drupal 9.0.0. The extension list parameter is now required in the ConfigImporter constructor. See https://www.drupal.org/node/2943918', E_USER_DEPRECATED);

mcdruid’s picture

StatusFileSize
new27.31 KB

Thanks for the reviews!

This is just a reroll initially; I'll work on the changes next.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new27.98 KB
new2.81 KB

I believe we'll need to add deprecation tests. I'll look at #1681832-145: Password reset form has no flood protection for an example.

We may still want to address #51.1 if that's practical?

Checking whether tests pass with these changes first though.

mcdruid’s picture

StatusFileSize
new2.81 KB

Oops, interdiff snafu.

The last submitted patch, 54: 2983395-54.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

mcdruid’s picture

StatusFileSize
new27.97 KB
new3.88 KB

Think I managed to re-introduce a couple of Url-related deprecations with the reroll.

Fixed those and a couple of other untidy codesniffer-type issues.

Having removed the type-hinting for UserFloodControl there's now an Unused use statement for it, but I'm guessing we can ignore that in this situation.

Still need to add the deprecation tests if this patch passes.

larowlan’s picture

This is looking good, I've got some suggestions around the API for future-proofing and design aspects.

  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -114,8 +114,8 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn
    +  public function __construct(UserFloodControl $user_flood_control, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats, LoggerInterface $logger) {
    

    I think we should be typehinting an interface here, but I think we need to remove the typehint altogether and do the BC dance 💃

  2. +++ b/core/modules/user/src/Event/UserFloodEvent.php
    @@ -0,0 +1,39 @@
    +  public function __construct(array $details) {
    +    $this->details = $details;
    

    I think this is an anti-pattern, lets keep these as separate variables and separate arguments/properties on the object with their own getters.

    It will use less memory, but also make the API more explicit/discoverable

  3. +++ b/core/modules/user/src/EventSubscriber/UserFloodSubscriber.php
    @@ -0,0 +1,75 @@
    +      $details = $floodEvent->getDetails();
    ...
    +        list($uid, $ip) = explode('-', $details['identifier']);
    

    it feels like we could be smarter about this, and avoid leaking implementation details outside the event by adding methods on the event to get the iP and uid as well as a 'hasIpAddress' method

    Otherwise, all the consuming code will have to have knowledge of the identifier's internal format, and if we want to change it later we'll have to do it in N places, or cause a BC break.

    If we keep it internal to the event, we can change it later without impacting consuming code.

  4. +++ b/core/modules/user/src/EventSubscriber/UserFloodSubscriber.php
    @@ -0,0 +1,75 @@
    +        $this->logger->notice('Flood control blocked login attempt for uid %uid from %ip', ['%uid' => $uid, '%ip' => $ip]);
    +      }
    +      else {
    +        $this->logger->notice('Flood control blocked login attempt for uid %uid', ['%uid' => $details['identifier']]);
    

    nit: we could return early here and avoid the else

  5. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -50,20 +54,31 @@ class UserLoginForm extends FormBase {
    +    if ($user_flood_control instanceof FloodInterface) {
    +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and will be replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/2983395', E_USER_DEPRECATED);
    +      $user_flood_control = \Drupal::service('user.flood_control');
    +    }
    +    $this->userFloodControl = $user_flood_control;
    

    should we do the same thing for the other controller ?

    however, I think what we should be doing here is making UserFloodControl implement FloodInterface and proxy the calls to the decorated storage.

    And then this becomes !$user_flood_control instanceof UserFloodControlInterface

  6. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -212,22 +228,25 @@ public function validateAuthentication(array &$form, FormStateInterface $form_st
    +    /** @var \Drupal\Core\Flood\FloodInterface $flood_storage */
    +    $flood_storage = $this->userFloodControl->storage();
    ...
    +      $flood_storage->register('user.failed_login_ip', $flood_config->get('ip_window'));
    

    doesn't the ->storage method type-hint it's return?

    however, I think this is a code-smell - we shouldn't be talking to our neighbour's neighbour.

    By making UserFloodControl implement flood interface and decorate the existing flood storage, these changes can be reverted

  7. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -212,22 +228,25 @@ public function validateAuthentication(array &$form, FormStateInterface $form_st
    -          $form_state->setErrorByName('name', $this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
    +          $this->messenger->addError($this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
    ...
    -          $form_state->setErrorByName('name', $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
    +          $this->messenger->addError($this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
    ...
    +        throw new AccessDeniedHttpException('Access is blocked because of flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);
    

    This is a behaviour change - is there a reason for that? Edit: from the issue summary

    The form gets an error set on it, but the response is still a 200 which means that access log entries cannot be identified by anyone interested in whether a site is being brute-forced.

    Are there BC implications to this? We're immediately halting execution, so other form submission/validation handlers won't run. I think that might be an issue. Should we be using $formState->setResponse() instead? So that other processing continues first?

    Also, I might be wrong, but I don't see a new test for the 403 status code - if we don't have that - we should add it.

  8. +++ b/core/modules/user/src/UserFloodControl.php
    @@ -0,0 +1,102 @@
    +class UserFloodControl {
    

    I feel like we should have an interface here if we're making it a service, so people can swap/typehint.

    In addition, I think we should be making this implement FloodInterface and proxy all the related calls to the storage.

    Then calling code doesn't need to change?

  9. +++ b/core/modules/user/src/UserFloodControl.php
    @@ -0,0 +1,102 @@
    +    else {
    

    the else isn't needed here, we returned early 🎉

  10. +++ b/core/modules/user/src/UserFloodControl.php
    @@ -0,0 +1,102 @@
    +      switch ($name) {
    +        case 'user.failed_login_ip':
    +          $event_name = UserEvents::FLOOD_BLOCKED_IP;
    +          break;
    +
    +        case 'user.failed_login_user':
    +        case 'user.http_login':
    +          $event_name = UserEvents::FLOOD_BLOCKED_USER;
    +          break;
    +      }
    

    nit: my personal preference would be to have a map of event names to flood names and use that to convert, but only because I think switch is an anti-pattern.

  11. +++ b/core/modules/user/src/UserFloodControl.php
    @@ -0,0 +1,102 @@
    +          [
    +            'name' => $name,
    +            'threshold' => $threshold,
    +            'window' => $window,
    +            'identifier' => $identifier,
    +          ]
    

    any reason we pass these as a map and not as seperate arguments?

  12. +++ b/core/modules/user/user.services.yml
    @@ -71,3 +71,14 @@ services:
    +  user.flood_control:
    

    yes, given this is a service, we definitely want an interface for this so we can make it swappable.

larowlan’s picture

FYI: The neighbour's neighbour thing is called Law of Demeter in OO design - its not just me making up some arbitrary thing :)

kim.pepper’s picture

Status: Needs review » Needs work
  1. If we are adding a new service, then why not a new UserFloodControlInterface?
  2. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -50,20 +54,31 @@ class UserLoginForm extends FormBase {
    +    if ($user_flood_control instanceof FloodInterface) {
    

    Maybe we can invert this and do if (!$user_flood_control instanceof UserFloodControl)

  3. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -162,6 +162,8 @@ public static function create(ContainerInterface $container) {
    +    $flood_storage = $this->userFloodControl->storage();
    
    @@ -183,7 +185,7 @@ public function login(Request $request) {
    +      $flood_storage->clear('user.http_login', $this->getLoginFloodIdentifier($request, $credentials['name']));
    

    I think that if you have a service, then it shouldn't expose the internals like this. Maybe hide UserFloodControl::storage() and add a UserFloodControl::clear()? I guess then it's looking more like FloodInterface...

    Have we considered just implementing FloodInterface, and adding an EventDispatchingFloodWrapper that wraps a flood?

mcdruid’s picture

Thanks for the really detailed reviews and excellent suggestions.

Looking at @larowlan's comments:

I think what we should be doing here is making UserFloodControl implement FloodInterface and proxy the calls to the decorated storage.

And then this becomes !$user_flood_control instanceof UserFloodControlInterface

...making UserFloodControl implement flood interface and decorate the existing flood storage ...

...and @kim.pepper's question (which was actually in slack):

Why can’t we just have an event dispatching implementation of FloodInterface?

I'm trying to get my head around the proposed architecture here; does this make sense?

interface UserFloodControlInterface extends FloodInterface
class UserFloodControl implements UserFloodControlInterface
user.flood_control:
    class: Drupal\user\UserFloodControl
    decorates: flood
    arguments: ['@user.flood_control.inner', '@event_dispatcher', '@request_stack']

Noting that this is partially the direction I was going in #29 but @alexpott's feedback made me change tack (which was perhaps down to my misinterpretation?)

For decoration, I'm looking at https://www.previousnext.com.au/blog/decorated-services-drupal-8 and hoping that this now works properly (the core issue that mentions is fixed).

I think the pseudo-code above makes sense to me and I can work on it, but would appreciate a sanity check.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new28.64 KB
new9.26 KB

Here's a first pass at implementing the architecture changes outlined in #61.

I looked at \Drupal\jsonapi\Routing\ReadOnlyModeMethodFilter for some ideas; it looks somewhat similar in terms of the decoration and implementing an interface which extends another interface etc..

\Drupal\Tests\user\Functional\UserLoginHttpTest and \Drupal\Tests\user\Functional\UserLoginTest both pass for me locally, so let's see if anything else is broken.

I've fixed a couple of the other things along the way, but still on the todo list:

  • 58.2
  • 58.3
  • 58.7 (the 403 is in the tests already though)
  • 58.9
  • 58.10
  • 58.11
  • Deprecation tests.

I'll work my way through these ASAP.

I'm not sure if {@inheritdoc} is legit for the methods where we're calling the decorated flood service's methods?

mcdruid’s picture

StatusFileSize
new31.12 KB
new10.31 KB

This patch addresses:

  • 58.2
  • 58.3 - caveat: the logic's moved into \Drupal\user\Event\UserFloodEvent::__construct() but still feels a little flimsy.
  • 58.9
  • 58.11

I tried to implement the suggestion in 58.7 of using $form_state->setResponse instead of throwing an exception:

diff --git a/core/modules/user/src/Form/UserLoginForm.php b/core/modules/user/src/Form/UserLoginForm.php
index 2906f954aa..ca5e0af68d 100644
--- a/core/modules/user/src/Form/UserLoginForm.php
+++ b/core/modules/user/src/Form/UserLoginForm.php
@@ -13,7 +13,6 @@
 use Drupal\user\UserFloodControl;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
 
 /**
  * Provides a user login form.
@@ -237,13 +236,13 @@ public function validateFinal(array &$form, FormStateInterface $form_state) {
 
       if ($flood_control_triggered = $form_state->get('flood_control_triggered')) {
         if ($flood_control_triggered == 'user') {
-          $this->messenger->addError($this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
+          $form_state->setErrorByName('name', $this->formatPlural($flood_config->get('user_limit'), 'There has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', 'There have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
         }
         else {
           // We did not find a uid, so the limit is IP-based.
-          $this->messenger->addError($this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
+          $form_state->setErrorByName('name', $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
         }
-        throw new AccessDeniedHttpException('Access is blocked because of flood prevention.', NULL, Response::HTTP_TOO_MANY_REQUESTS);
+        $form_state->setResponse(new Response('Too many failed login attempts.', 403));
       }
       else {
         // Use $form_state->getUserInput() in the error message to guarantee

This seems like a great idea (and would mean we don't need the $messenger service which required a BC dance etc..)

However, it doesn't seem to work.

We get through FormBuilder->processForm() with the 403 Response sitting in the $form_state, but we land at the end of FormBuilder->buildForm():

    // If the form returns a response, skip subsequent page construction by
    // throwing an exception.
    // @see Drupal\Core\EventSubscriber\EnforcedFormResponseSubscriber
    //
    // @todo Exceptions should not be used for code flow control. However, the
    //   Form API does not integrate with the HTTP Kernel based architecture of
    //   Drupal 8. In order to resolve this issue properly it is necessary to
    //   completely separate form submission from rendering.
    //   @see https://www.drupal.org/node/2367555
    if ($response instanceof Response) {
      throw new EnforcedResponseException($response);
    }

    // If this was a successful submission of a single-step form or the last
    // step of a multi-step form, then self::processForm() issued a redirect to
    // another page, or back to this page, but as a new request. Therefore, if
    // we're here, it means that this is either a form being viewed initially
    // before any user input, or there was a validation error requiring the form
    // to be re-displayed, or we're in a multi-step workflow and need to display
    // the form's next step. In any case, we have what we need in $form, and can
    // return it for rendering.
    return $form;
  }

Because we've not returned an actual Response, the processing gets all the way to the end here and the form is rendered (as a 200) despite the 403 Response still being in the $form_state. Which is a bit frustrating.

We could try returning the Response so that we end up throwing the EnforcedResponseException, but as we're in a validate function here, I'm not certain how we'd do so.

Plus I'm not sure what we're gaining; if we return a basic 403 response (a bit like \Drupal\ban\BanMiddleware::handle() does) the error messages we're setting won't be displayed, and we'd just end up throwing a different exception a few steps later, as far as I can see.

I'm inclined to stick with the messenger / AccessDeniedHttpException implementation unless there's some Form API magic tricks that I'm missing here - which is entirely possible.

That leaves:

  • 58.10 - refactoring the switch/case in \Drupal\user\UserFloodControl::isAllowed()
  • Deprecation tests.
larowlan’s picture

  1. +++ b/core/modules/user/user.services.yml
    @@ -73,7 +73,8 @@
    +    decorates: flood
    

    I don't think we need this line, we're decorating but not in the symfony sense, just in the general OO sense. The symfony sense makes it take over the item being decorated, we want this as a stand-alone service in my book, not to replace the flood service - which I think is what alexpott said in 29 too.
    Sorry for using an overloaded term like decorate, really, its just proxying. i.e. in summary, remove the 'decorates:' line from services.yml and see what breaks?

  2. +++ b/core/modules/user/src/UserFloodControlInterface.php
    @@ -0,0 +1,12 @@
    +interface UserFloodControlInterface extends FloodInterface {
    

    should this have methods - anything public in UserFloodControl above what is in FloodInterface should be here I think?

  3. +++ b/core/modules/user/src/Event/UserFloodEvent.php
    @@ -10,30 +10,157 @@
    +    if (is_numeric($identifier)) {
    +      $this->uid = $identifier;
    +    }
    +    else if (strpos($identifier, '-') !== FALSE) {
    +      list($uid, $ip) = explode('-', $identifier);
    +      $this->uid = $uid;
    +      $this->ip = $ip;
    +    }
    +    else {
    +      $this->ip = $identifier;
    +    }
    

    this could be simplified by adding returns.

    Then it becomes

    if
    ...
    return

    if
    ...
    return

    ...

    And then we have no elseif or else

    And having it as an internal implementation detail of the event++

  4. +++ b/core/modules/user/src/Event/UserFloodEvent.php
    @@ -10,30 +10,157 @@
    +  public function hasIp() {
    ...
    +  public function hasUid() {
    

    ❤️

  5. Because we've not returned an actual Response, the processing gets all the way to the end here and the form is rendered (as a 200) despite the 403 Response still being in the $form_state. Which is a bit frustrating.

    I think you may need to step through \Drupal\Core\Form\FormSubmitter::doSubmitForm - it should return a response if you've called $formState->setResponse, which should bubble out to that processForm call. But there are a few code paths in that method, and only the last one returns a response, so its possible that one of the earlier code paths is executing, because of some other value in form state.

mcdruid’s picture

Thanks for the quick feedback.

64.1 Okay, thanks for clarifying. Hopefully it'll be easy enough to pass flood as a normal service instead; I'll change that around.

64.2 "anything public in UserFloodControl above what is in FloodInterface" - I don't think there are any methods other than those in FloodInterface; we're effectively just decorating adding to isAllowed().

64.3 Okay, will put returns in.

64.4 :)

64.5 I think the reason I wasn't getting as far as doSubmitForm is this (in \Drupal\Core\Form\FormBuilder::processForm):

      // If there are no errors and the form is not rebuilding, submit the form.
      if (!$form_state->isRebuilding() && !FormState::hasAnyErrors()) {
        $submit_response = $this->formSubmitter->doSubmitForm($form, $form_state);
        // If this form was cached, delete it from the cache after submission.
        if ($form_state->isCached()) {
          $this->deleteCache($form['#build_id']);
        }
        // If the form submission directly returned a response, return it now.
        if ($submit_response) {
          return $submit_response;
        }
      }

There are errors, as we're setting them on the form when flood control kicks in during validation.

If I change the errors back to messages:

          // We did not find a uid, so the limit is IP-based.
          //$form_state->setErrorByName('name', $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
          $this->messenger->addError($this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
        }
        $form_state->setResponse(new Response('Too many failed login attempts.', 403));

...we do then get the form to submit, but we hit an error because the $uid has not been set:

'PHP message: Error: Call to a member function id() on null in /data/drupal-8.x/core/modules/user/src/Form/UserLoginForm.php on line 155 #0 [internal function]: Drupal\\user\\Form\\UserLoginForm->submitForm(Array, Object(Drupal\\Core\\Form\\FormState))\n#1 /data/drupal-8.x/core/lib/Drupal/Core/Form/FormSubmitter.php(111): call_user_func_array(Array, Array)\n#2 /data/drupal-8.x/core/lib/Drupal/Core/Form/FormSubmitter.php(51): Drupal\\Core\\Form\\FormSubmitter->executeSubmitHandlers(...

We can get around this by setting the uid:

        $form_state->setResponse(new Response('Too many failed login attempts.', 403));
        $form_state->set('uid', 0);

...which does then result in the (very plain) 403 being served. The error message we set in \Drupal\user\Form\UserLoginForm::validateFinal disappears into the ether. (Well, it will be displayed if the blocked user visits another page.)

I believe this is actually throwing an exception from \Drupal\Core\Form\FormBuilder::buildForm:

    if ($response instanceof Response) {
      throw new EnforcedResponseException($response);
    }

Do we really want to do that instead of throwing the AccessDeniedHttpException ourselves? Doing so gives us the 403 and renders the access denied page, including displaying the error message.

mcdruid’s picture

StatusFileSize
new34.88 KB
new11.41 KB

This patch addresses:

  • 58.10 - switch/case replaced with a map of event names.
  • 64.1 - flood is now passed as a normal service instead of using Symfony decoration.
  • 64.3 - added returns instead of elseif/else.
  • Added deprecation tests.
  • References to this node in the deprecation messages replaced with the CR instead.
  • Some general tidying up.

So the only thing I haven't done is remove the AccessDeniedHttpException in UserLoginForm and set a 403 Response using $form_state->setResponse().

That would be a fairly easy change to make if it really would be better.

kim.pepper’s picture

Status: Needs review » Reviewed & tested by the community

I think this is ready.

The last submitted patch, 53: 2983395-53.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

andypost’s picture

+++ b/core/modules/user/src/Controller/UserAuthenticationController.php
@@ -114,8 +114,12 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn
+      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and will be replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);

+++ b/core/modules/user/src/Form/UserLoginForm.php
@@ -50,20 +53,31 @@ class UserLoginForm extends FormBase {
+      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and will be replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);

+++ b/core/modules/user/tests/src/Kernel/Form/UserLoginFormTest.php
@@ -0,0 +1,42 @@
+   * @expectedDeprecation Passing the flood service to Drupal\user\Form\UserLoginForm::__construct is deprecated in drupal:8.8.0 and will be replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148
+   * @expectedDeprecation Calling Drupal\user\Form\UserLoginForm::__construct without the $messenger parameter is deprecated in drupal:8.8.0 and is required in drupal:9.0.0. See https://www.drupal.org/node/3067148

needs formatting according standards https://www.drupal.org/core/deprecation

andypost’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new3.51 KB
new34.86 KB

Basically s/will be/is

Status: Needs review » Needs work

The last submitted patch, 70: 2983395-70.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

andypost’s picture

Status: Needs work » Needs review
StatusFileSize
new2.6 KB
new34.8 KB

Fix CS and deprecation message in test

mcdruid’s picture

StatusFileSize
new34.8 KB
new414 bytes

Thanks @andypost - this fixes one tiny typo in your patch.

I think we can go back to RTBC if this passes.

mcdruid’s picture

StatusFileSize
new34.79 KB
new622 bytes

D'oh. As soon as I hit Save I noticed a typo I'd made in the comments for hasIp() / hasUid().

mcdruid’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC per #67 as we've only made a couple of very minor text changes since then to tidy up.

mcdruid’s picture

Any chance we can get this into the alpha for 8.8.x?

larowlan’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/modules/user/src/Form/UserLoginForm.php
@@ -214,20 +228,21 @@ public function validateFinal(array &$form, FormStateInterface $form_state) {
+          $this->messenger->addError($this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href=":url">request a new password</a>.', [':url' => Url::fromRoute('user.pass')->toString()]));
         }
+        throw new AccessDeniedHttpException('Too many failed login attempts.');

i'm still not sure about this change, it circumvents other form processing, returning immediately and removes the ability for other form validation/submission handlers to run

From #66 you indicated this would be fairly easy?

I think on the basis of the fact we are cutting other validation handlers out of the flow, we need to go that route, sorry.

Version: 8.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

mcdruid’s picture

StatusFileSize
new34.08 KB
new5.56 KB

Okay. If we're setting a Response into the form state instead of throwing the exception, I think we can do away with using the messenger service. So that's less code, and removes one of the changes to the form's constructor, all of which is good.

In #65 I mentioned doing:

$form_state->set('uid', 0);

...to avoid an error in UserLoginForm->submitForm().

Instead of that hack, I've added a check to the submitForm() method so we can bail out if there's no uid.

mcdruid’s picture

Are we back to RTBC?

mcdruid’s picture

StatusFileSize
new617 bytes
new34.11 KB

Rerolled for 8.9.x

There was a small conflict caused by adding dblog as a module to install at the start of \Drupal\Tests\user\Functional\UserLoginTest where the $defaultTheme had also been added.

darrenwh’s picture

Status: Needs review » Needs work
+++ b/core/modules/user/src/Event/UserFloodEvent.php
@@ -0,0 +1,165 @@
+  public function __construct($name, $threshold, $window, $identifier) {

Very minor, but this method is missing typecasting for the variables

prabha1997’s picture

Assigned: Unassigned » prabha1997
prabha1997’s picture

Assigned: prabha1997 » Unassigned
Status: Needs work » Needs review
StatusFileSize
new20.79 KB
new12.18 KB
swatichouhan012’s picture

StatusFileSize
new34.13 KB
new574 bytes

i have Updated patch #82 wrt comment #83, Kindly review new patch.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

larowlan’s picture

Status: Needs review » Needs work

Re #83 and #86 we don't do scalar type-hints yet that I'm aware of.
Re #85 - the patch changed by 15Kb, looks like something went wrong there.
So this is a review of #82

  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -114,8 +114,12 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn
    +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and is replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);
    

    this will need to say 9.1.0 now (sorry 😿)

  2. +++ b/core/modules/user/src/Form/UserLoginForm.php
    @@ -214,20 +223,21 @@ public function validateFinal(array &$form, FormStateInterface $form_state) {
    +      if ($userFloodControl_control_user_identifier = $form_state->get('flood_control_user_identifier')) {
    +        $this->userFloodControl->register('user.failed_login_user', $flood_config->get('user_window'), $userFloodControl_control_user_identifier);
    

    looks like a find and replace went wrong here

narendra.rajwar27’s picture

Assigned: Unassigned » narendra.rajwar27
narendra.rajwar27’s picture

Status: Needs work » Needs review
StatusFileSize
new34.11 KB
new3 KB

Updated patch as mentioned in #88 and also for branch 9.1.x. as patch was not applying for 9.1.x.

Status: Needs review » Needs work

The last submitted patch, 90: 2983395-90.patch, failed testing. View results

narendra.rajwar27’s picture

Status: Needs work » Needs review
StatusFileSize
new34.17 KB
new684 bytes

As patch applied and working as expected. On multiple failed login attempts it is logging meaningful message. But after every attempt, it is breaking the login form.

See screenshot:
Only local images are allowed.

Updating patch.

Status: Needs review » Needs work

The last submitted patch, 92: 2983395-91.patch, failed testing. View results

narendra.rajwar27’s picture

Status: Needs work » Needs review
StatusFileSize
new4.05 KB
new34.17 KB
mcdruid’s picture

StatusFileSize
new34 KB

Thanks @narendra.rajwar27, however I _think_ the deprecation would now be happening in 9.1.x and the BC layer would be removed in 10.0.0

@larowlan also mentioned in #88 that type hinting of the scalar params is not something we should be adding (yet).

As that review was of #82 I've based this new patch on that one, but interdiff isn't working between 82 and 95.

These are the actual changes, but sadly without the context of the filenames etc..:

< +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and is replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);
> +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:9.1.0 and is replaced by user.flood_control in drupal:10.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);
< +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and is replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);
> +      @trigger_error('Passing the flood service to ' . __METHOD__ . ' is deprecated in drupal:9.1.0 and is replaced by user.flood_control in drupal:10.0.0. See https://www.drupal.org/node/3067148', E_USER_DEPRECATED);
< -      if ($flood_control_user_identifier = $form_state->get('flood_control_user_identifier')) {
>        if ($flood_control_user_identifier = $form_state->get('flood_control_user_identifier')) {
< +      if ($userFloodControl_control_user_identifier = $form_state->get('flood_control_user_identifier')) {
< +        $this->userFloodControl->register('user.failed_login_user', $flood_config->get('user_window'), $userFloodControl_control_user_identifier);
> +        $this->userFloodControl->register('user.failed_login_user', $flood_config->get('user_window'), $flood_control_user_identifier);
< -  public static $modules = ['hal'];
< +  public static $modules = ['hal', 'dblog'];
> -  protected static $modules = ['hal'];
> +  protected static $modules = ['hal', 'dblog'];
< +   * @expectedDeprecation Passing the flood service to Drupal\user\Controller\UserAuthenticationController::__construct is deprecated in drupal:8.8.0 and is replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148
> +   * @expectedDeprecation Passing the flood service to Drupal\user\Controller\UserAuthenticationController::__construct is deprecated in drupal:9.1.0 and is replaced by user.flood_control in drupal:10.0.0. See https://www.drupal.org/node/3067148
< +   * @expectedDeprecation Passing the flood service to Drupal\user\Form\UserLoginForm::__construct is deprecated in drupal:8.8.0 and is replaced by user.flood_control in drupal:9.0.0. See https://www.drupal.org/node/3067148
> +   * @expectedDeprecation Passing the flood service to Drupal\user\Form\UserLoginForm::__construct is deprecated in drupal:9.1.0 and is replaced by user.flood_control in drupal:10.0.0. See https://www.drupal.org/node/3067148

We'll need to update the draft CR mentioned in those deprecation messages too.

The last submitted patch, 94: 2983395-94.patch, failed testing. View results

mcdruid’s picture

Are you happy for this to go back to unassigned @narendra.rajwar27 ?

I think we should be back at RTBC with any luck, but it'd be good if someone else could confirm that.

narendra.rajwar27’s picture

Assigned: narendra.rajwar27 » Unassigned
narendra.rajwar27’s picture

Status: Needs review » Needs work

@mcdruid, i have mentioned an issue and added screenshot in #92. It is not addressed. you can refer the interdiff for the same in comment #92.

mcdruid’s picture

Status: Needs work » Needs review

Sorry @narendra.rajwar27 I don't think I understand.

It's intentional to block logins via flood when too many have been unsuccessful, and part of the improvement we're trying to make here is to return a 403 when this happens (see the IS).

One of the test failures in #92 was because the assertion checking for that 403 received a 200 instead:

1) Drupal\Tests\user\Functional\UserLoginTest::testGlobalLoginFloodControl
Behat\Mink\Exception\ExpectationException: Current response status code is 200, but 403 expected.

What is the issue that needs to be addressed?

narendra.rajwar27’s picture

StatusFileSize
new35.59 KB

@mcdruid, Here it is:

After applying patch, On each login attempt failure. I am getting broken login form. So issue needs work on this part.
Please refer interdiff in comment #92

narendra.rajwar27’s picture

Status: Needs review » Needs work

making this to needs work.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new57.15 KB
new34.82 KB

@narendra.rajwar27 I've just manually re-tested, and I think the behaviour you're describing is the desired functionality of the user login flood control, unless I'm missing something.

First we can lower at least one of the limits in config to make manual testing easier:

$ drush cget user.flood
uid_only: false
ip_limit: 50
ip_window: 3600
user_limit: 5
user_window: 21600

$ drush cset user.flood ip_limit 2

 Do you want to update ip_limit key in user.flood config? (yes/no) [yes]:
 > y

If we try a few logins which will fail now, the first few will re-render the login form along with an error message:

unrecognized username or password

Once we exceed the low IP flood limit though, Drupal responds with a very simple error to explain that we've been temporarily blocked, and doesn't re-render the login form:

this IP is temporarily blocked

That's not a bug, that's the desired behaviour.

We don't need to re-render the login form as we won't be allowing login attempts from this IP for a while. It's cheaper to return the simple message; often Drupal will be responding to a bot attempting to brute force logins in this situation, so that's better. We also want to return a 403 as we are denying access, and it's good for that to be logged by the webserver (per the IS).

The behaviour is pretty much the same when flood control is triggered for a particular user name / account, except there's a different threshold and the message is slightly different - that's what your screenshot is showing.

Does that help to explain what you're seeing happen, or is there something else that we need to address?

narendra.rajwar27’s picture

That's not a bug, that's the desired behaviour.

@mcdruid, If that is the case then it is ok.

amjad1233’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new101.97 KB

I applied the patch, which applies cleanly and seems to be working as described.

Also, Flood logging is correct with 403 I can see entries as below screenshot.

Just one concern was if I use a legitimate username it's kind of giving out a hint of that user exists in the system.

For example, if I have a user test123 and I keep entering the wrong password after 5 attempts I get the error of flooding. But if I try with a non-existing username it keeps going.

With that said, I found this is the same behaviour in the password reset screen. When you enter a non-existing username it will say " is not recognized as a username or an email address." while if you enter the right username it will say email has been sent.

Which means someone can figure out if the username exists in the system.

Reference: https://security.stackexchange.com/questions/62661/generic-error-message...

While that's a bigger discussion and I am sure there will be some discussion around it in community. I am RTBTC this one.

larowlan’s picture

Thanks @amjad1233 please note that we have a policy on that - https://www.drupal.org/drupal-security-team/security-team-procedures/dis... - so I think that's ok.

larowlan’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/user/src/Event/UserEvents.php
@@ -0,0 +1,24 @@
+   * The name of the event fired when a login is blocked by flood control.
...
+   * The name of the event fired when a login is blocked by flood control.

Sorry to do this, but I think we're a bit shy of the docs-gate on this.

Looking at other Event classes in core e.g ConfigEvents they also have a long-description in the constant docblocks describing what sort of things listeners can do and the argument that is passed as well as some @see tags to the argument and the places where the event is fired from.

I think we can add some more information here, so people can get the maximum use of this new extension point.

I really appreciate the effort that's gone into this, and its so close. But I think we need to make sure we document the event to similar levels of existing events.

I'll keep a close eye on this issue.

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new1.31 KB
new34.78 KB

Definitely agree that additional documentation is beneficial for the new events.

ConfigEvents was a good example, thanks.

mcdruid’s picture

StatusFileSize
new723 bytes
new34.78 KB

The wrapping of one of the comments was a little wrong.

The last submitted patch, 108: 2983395-108.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 109: 2983395-109.patch, failed testing. View results

mcdruid’s picture

Status: Needs work » Needs review
StatusFileSize
new1.46 KB
new34.81 KB
  18x: AssertLegacyTrait::assertResponse() is deprecated in drupal:8.2.0 and is removed from drupal:10.0.0. Use $this->assertSession()->statusCodeEquals() instead. See https://www.drupal.org/node/3129738
    12x in UserLoginTest::testGlobalLoginFloodControl from Drupal\Tests\user\Functional
    6x in UserLoginTest::testPerUserLoginFloodControl from Drupal\Tests\user\Functional

Okay, let's try that again.

amjad1233’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new12.49 KB

Patch applies cleanly works as expected.

larowlan’s picture

Saving issue credit

  • larowlan committed 9461f4a on 9.1.x
    Issue #2983395 by mcdruid, narendra.rajwar27, andypost, yogeshmpawar,...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Committed 9461f4a and pushed to 9.1.x. Thanks!

Published the change record.

Thanks everyone

andypost’s picture

+++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
@@ -30,7 +30,7 @@ class UserLoginHttpTest extends BrowserTestBase {
-  protected static $modules = ['hal'];
+  protected static $modules = ['hal', 'dblog'];

+++ b/core/modules/user/tests/src/Functional/UserLoginTest.php
@@ -18,6 +18,13 @@ class UserLoginTest extends BrowserTestBase {
+  public static $modules = ['dblog'];

+++ b/core/modules/user/tests/src/Kernel/Controller/UserAuthenticationControllerTest.php
@@ -0,0 +1,52 @@
+  public static $modules = ['user'];

+++ b/core/modules/user/tests/src/Kernel/Form/UserLoginFormTest.php
@@ -0,0 +1,41 @@
+  public static $modules = ['user'];

It could use to update it to protected, filed follow-up #3150070: Fix new test modules variable visibility

mxr576’s picture

Should this new service also used by the basic_auth.authentication.basic_auth service instead of flood?

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

mcdruid’s picture

@cilefen yes it looks like this did change the way that the flood "access denied" messages are themed. I can't honestly recall whether that was intentional.

Let's continue the discussion in the new issue you linked to. Thanks!