Hi I have a patch for bug #6337, in this patch you have the possibility to set in the configuration file the value MAX_LOGIN_ATTEMPTS which sets the number of failed login attempts that are allowed. If this value is exceeded the account is locked and can be reset by an administrator. By setting the variable to the value zero this feature is deactivated this is necessary if the account of the administrator was locked.

Comment:

Unfortunately the test cases fail because there seems to be a bug with the migration, but unfortunately I was not able to locate this bug.

Unfortunately, in my opinion, the documentation does not sufficiently explain how to correctly create the migrations.

I would be very happy if you could expand the documentation in the future what this concerns and create a detailed guide to create a migration.  (This also concerns the instructions for the integration test)

With kind regards,

Florian Sabonchi

>From beb82a64075e7c52904669178804ae0835d0e924 Mon Sep 17 00:00:00 2001
From: Florian Sabonchi <sabon...@posteo.de>
Date: Sun, 11 Jul 2021 00:47:13 +0200
Subject: [PATCH 1/2] first draft for bruteforce_protection

---
 web/migrations/versions/6650c52670c2_.py      | 33 ++++++++++++++++
 web/pgadmin/authenticate/__init__.py          | 39 ++++++++++++++++---
 web/pgadmin/model/__init__.py                 |  4 +-
 web/pgadmin/tools/user_management/__init__.py | 16 ++++++--
 .../static/js/user_management.js              | 13 +++++++
 5 files changed, 94 insertions(+), 11 deletions(-)
 create mode 100644 web/migrations/versions/6650c52670c2_.py

diff --git a/web/migrations/versions/6650c52670c2_.py b/web/migrations/versions/6650c52670c2_.py
new file mode 100644
index 000000000..90cc17d36
--- /dev/null
+++ b/web/migrations/versions/6650c52670c2_.py
@@ -0,0 +1,33 @@
+
+"""empty message
+
+Revision ID: 6650c52670c2
+Revises: c465fee44968
+Create Date: 2021-07-10 18:12:38.821602
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+from pgadmin import db
+
+revision = '6650c52670c2'
+down_revision = 'c465fee44968'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    db.engine.execute(
+        'ALTER TABLE user ADD COLUMN locked BOOLEAN DEFAULT FALSE'
+    )
+    db.engine.execute(
+        'ALTER TABLE user ADD COLUMN login_attempts int DEFAULT 0'
+    )
+
+
+def downgrade():
+    # pgAdmin only upgrades, downgrade not implemented.
+    pass
diff --git a/web/pgadmin/authenticate/__init__.py b/web/pgadmin/authenticate/__init__.py
index db42d230a..5a21d3d9b 100644
--- a/web/pgadmin/authenticate/__init__.py
+++ b/web/pgadmin/authenticate/__init__.py
@@ -12,18 +12,18 @@
 import config
 import copy
 
-from flask import current_app, flash, Response, request, url_for,\
+from flask import current_app, flash, Response, request, url_for, \
     session, redirect
 from flask_babelex import gettext
 from flask_security.views import _security
 from flask_security.utils import get_post_logout_redirect, \
-    get_post_login_redirect
+    get_post_login_redirect, logout_user
 
+from pgadmin import db, User
 from pgadmin.utils import PgAdminModule
 from pgadmin.utils.constants import KERBEROS, INTERNAL, OAUTH2, LDAP
 from pgadmin.authenticate.registry import AuthSourceRegistry
 
-
 MODULE_NAME = 'authenticate'
 auth_obj = None
 
@@ -46,14 +46,29 @@ def login():
 
     auth_obj = AuthSourceManager(form, copy.deepcopy(
         config.AUTHENTICATION_SOURCES))
-    if OAUTH2 in config.AUTHENTICATION_SOURCES\
-            and 'oauth2_button' in request.form:
+    if OAUTH2 in config.AUTHENTICATION_SOURCES \
+        and 'oauth2_button' in request.form:
         session['auth_obj'] = auth_obj
 
     session['auth_source_manager'] = None
+
+    username = form.data['email']
+    user = User.query.filter_by(username=username).first()
+
+    if user:
+        if user.login_attempts >= config.MAX_LOGIN_ATTEMPTS > 0:
+            user.locked = True
+        else:
+            user.locked = False
+        db.session.commit()
+
     # Validate the user
     if not auth_obj.validate():
         for field in form.errors:
+            if user:
+                if config.MAX_LOGIN_ATTEMPTS > 0:
+                    user.login_attempts += 1
+                db.session.commit()
             for error in form.errors[field]:
                 flash(error, 'warning')
         return redirect(get_post_logout_redirect())
@@ -65,15 +80,27 @@ def login():
         status, msg = auth_obj.login()
         current_auth_obj = auth_obj.as_dict()
 
+        if status:
+            if user.login_attempts >= config.MAX_LOGIN_ATTEMPTS > 0:
+                flash(gettext('Account locked'),
+                      'warning')
+                logout_user()
+                return redirect(get_post_logout_redirect())
+
         if not status:
-            if current_auth_obj['current_source'] ==\
+            if current_auth_obj['current_source'] == \
                     KERBEROS:
                 return redirect('{0}?next={1}'.format(url_for(
                     'authenticate.kerberos_login'), url_for('browser.index')))
 
             flash(msg, 'danger')
             return redirect(get_post_logout_redirect())
+
         session['auth_source_manager'] = current_auth_obj
+
+        user.login_attempts = 0
+        db.session.commit()
+
         if 'auth_obj' in session:
             session.pop('auth_obj')
         return redirect(get_post_login_redirect())
diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py
index 3afef96eb..aea32c1e1 100644
--- a/web/pgadmin/model/__init__.py
+++ b/web/pgadmin/model/__init__.py
@@ -30,7 +30,7 @@ import uuid
 #
 ##########################################################################
 
-SCHEMA_VERSION = 30
+SCHEMA_VERSION = 31
 
 ##########################################################################
 #
@@ -80,6 +80,8 @@ class User(db.Model, UserMixin):
     # fs_uniquifier is required by flask-security-too >= 4.
     fs_uniquifier = db.Column(db.String(255), unique=True, nullable=False,
                               default=(lambda _: uuid.uuid4().hex))
+    login_attempts = db.Column(db.Integer, default=0)
+    locked = db.Column(db.Boolean(), default=False)
 
 
 class Setting(db.Model):
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 3aabd7749..9ee3e26ae 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -129,6 +129,10 @@ def validate_user(data):
     if 'auth_source' in data and data['auth_source'] != "":
         new_data['auth_source'] = data['auth_source']
 
+    if 'locked' in data and not data['locked']:
+        new_data['locked'] = data['locked']
+        new_data['login_attempts'] = 0
+
     return new_data
 
 
@@ -207,7 +211,8 @@ def user(uid):
                'email': u.email,
                'active': u.active,
                'role': u.roles[0].id,
-               'auth_source': u.auth_source
+               'auth_source': u.auth_source,
+               'locked': u.locked
                }
     else:
         users = User.query.all()
@@ -219,7 +224,8 @@ def user(uid):
                                'email': u.email,
                                'active': u.active,
                                'role': u.roles[0].id,
-                               'auth_source': u.auth_source
+                               'auth_source': u.auth_source,
+                               'locked': u.locked
                                })
 
         res = users_data
@@ -316,7 +322,8 @@ def create_user(data):
         'username': usr.username,
         'email': usr.email,
         'active': usr.active,
-        'role': usr.roles[0].id
+        'role': usr.roles[0].id,
+        'locked': usr.locked
     }
 
 
@@ -599,7 +606,8 @@ def update(uid):
                'email': usr.email,
                'active': usr.active,
                'role': usr.roles[0].id,
-               'auth_source': usr.auth_source
+               'auth_source': usr.auth_source,
+               'locked': usr.locked
                }
 
         return ajax_response(
diff --git a/web/pgadmin/tools/user_management/static/js/user_management.js b/web/pgadmin/tools/user_management/static/js/user_management.js
index ffed1d44d..6fac754d2 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/user_management.js
@@ -436,6 +436,19 @@ define([
             editable: function(m) {
               return (m.get('auth_source') == DEFAULT_AUTH_SOURCE);
             },
+          },{
+            id: 'locked',
+            label: gettext('Locked'),
+            type: 'switch',
+            cell: 'switch',
+            disabled: false,
+            sortable: false,
+            editable: function (m){
+              if (!m.get('locked')) {
+                return false;
+              }
+              return (m.get('id') != userInfo['id']);
+            },
           }],
           validate: function() {
             var errmsg = null,
-- 
2.25.1


>From c01dea5759e0f18bb83517848c683e20a105b7c9 Mon Sep 17 00:00:00 2001
From: Florian Sabonchi <sabon...@posteo.de>
Date: Mon, 12 Jul 2021 17:53:21 +0200
Subject: [PATCH 2/2] updated documentation

---
 docs/en_US/login.rst | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/docs/en_US/login.rst b/docs/en_US/login.rst
index d2b949910..d5c9dec20 100644
--- a/docs/en_US/login.rst
+++ b/docs/en_US/login.rst
@@ -56,3 +56,10 @@ Please note that your LDAP password cannot be recovered using this dialog. If
 you enter your LDAP username in the *Email Address/Username* field, and then
 enter your email to recover your password, an error message will be displayed
 asking you to contact the LDAP administrator to recover your LDAP password.
+
+Avoiding a bruteforce attack
+**************************
+
+You have the possibility to lock an account by setting ``MAX_LOGIN_ATTEMPTS``
+once it has reached the maximum number of login attempts.
+You can disable this feature by setting the value to zero.
\ No newline at end of file
-- 
2.25.1

Reply via email to